Notes on learning stage of developing my own React Native education app using Expo Framework - Part 4
In local Strapi development, the default server runs on port 1337 (e.g., http://localhost:1337).
However, when a project is deployed on Strapi Cloud, that internal port is not publicly exposed.
Strapi Cloud hosts each app behind a reverse proxy that serves traffic only on the standard web ports — 80 (HTTP) and 443 (HTTPS) — and internally forwards it to port 1337.
As a result, any URL containing :1337 (for example,
https://majestic-benefit-7638ba90d8.strapiapp.com:1337/api/...) will time out,
while the version without the port
(https://majestic-benefit-7638ba90d8.strapiapp.com/api/...) works correctly.
In short:
Use :1337 locally, but omit it on Strapi Cloud — the platform handles port routing automatically.
- To run local lms frontend with local strapi backed: npm run start:local-be
- To run local lms frontend with cloud strapi backed: npm run start:cloud-be
- To run local lms frontend with last used strapi backed: npm run start
So your test confirms:
-
On the web,
STRAPI_API_ADMIN_TOKENis not exposed. -
On mobile bundles, if you import it in client code, it could be exposed.
Security
Route handlers are executed in a sandboxed environment that is isolated from the client code. It means you can safely store sensitive data in the route handlers without exposing it to the client.
- Client code that imports code with a secret is included in the client bundle. It applies to all files in the app directory even though they are not a route handler file (such as suffixed with +api.ts).
- If the secret is in a <...>+api.ts file, it is not included in the client bundle. It applies to all files that are imported in the route handler.
- The secret stripping takes place in
expo/metro-configand requires it to be used in the metro.config.js.
add-user-course+api.ts run in a server-only sandbox. Admin secrets used there are not exposed to the client bundle, as long as the file is not imported into client-side code. This is a safe way to store and use STRAPI_API_ADMIN_TOKEN in your React Native + Expo project.origin in your app.json tells the Expo Router where the server-side API routes live for your mobile app to call.EXPO_PUBLIC_ are bundled into the client app. Since STRAPI_API_ADMIN_TOKEN has no such prefix, it remains private and cannot be accessed by mobile or web client code — even if mistakenly referenced.Why your code didn’t auto-play on Web
Browsers (especially Chrome, Safari, Edge, etc.) block autoplay with sound by default, unless:
-
The video is muted, or
-
The user has interacted with the page (e.g. clicked anywhere), or
-
The site has earned autoplay permission (for example, via repeated user interactions).
So even though your code calls:
Chrome may silently reject that .play() call, doing nothing — it won’t throw a visible error, but playback won’t start until the user interacts.
This is standard web media behavior — nothing wrong with your Expo setup.
✅ How to confirm this is the cause
Try this simple check in Chrome DevTools console (after loading your web app):
If you get a warning like: “Uncaught (in promise) DOMException: play() failed because the user didn't interact with the document first.”
then it’s definitely autoplay policy blocking you.
...
Me: document.querySelector("video").play();
gets the video to play.
CG: Perfect — that confirms 100% that your code and Expo setup are fine, and the issue is Chrome’s autoplay policy, not your app. 🎯
So what’s happening is exactly this:
-
Chrome blocks autoplay until a user interacts (e.g. click, tap, or keypress).
-
Your code’s
player.play()runs before that first interaction → silently ignored. -
When you manually run
document.querySelector("video").play()from DevTools (a user action), playback starts — proving everything else works.
Here’s a concise summary of that issue (and what it means for your project):
🧩 Issue Summary — “[expo-video] Autoplay doesn’t work for video on Web”
📄 Context
-
The issue affects Expo SDK 52+, including your setup.
-
Developers reported that:
after
player.replace()orplayer.loadAsync()does not start playback on Web, even though it works fine on Android and iOS.
⚠️ Root cause
-
The Expo Video web implementation uses an underlying
<video>element. -
Browsers (especially Chrome and Safari) block autoplay unless:
-
The video is muted, or
-
The call to
.play()happens during a user gesture event (click/tap).
-
-
Expo’s internal player abstraction doesn’t yet fully handle these restrictions on Web — so calling
player.play()programmatically afterreplace()doesn’t count as a “user gesture.”
💬 Maintainers confirmed
This is a limitation of browsers’ autoplay policies and not something we can bypass in Expo Video. Developers must trigger playback via a user interaction or start muted.
(Source: Expo GitHub issue #36350)
...
Here’s how it works in Expo / React Native builds (including Android APKs):
🧩 1. Environment variables in EAS web apps
For EAS web hosting, environment variables (like EXPO_PUBLIC_STRAPI_API_URL or STRAPI_API_ADMIN_TOKEN) are read dynamically at runtime from EAS Hosting.
So you can easily:
-
Change them in your project’s EAS dashboard, and
-
Restart the app — no rebuild needed.
That’s why your web app can adapt quickly.
🤖 2. Environment variables in Android (APK or AAB)
However, when you build an APK/AAB, Expo embeds those environment variables directly into the compiled JavaScript bundle during the build step.
That means:
-
If you later change your Strapi URL or API key,
-
The APK will still have the old values,
-
Because those were baked in at build time.
So yes — you’d have to rebuild the APK to update those values.
⚙️ 3. Practical strategies
Here are a few ways to handle this more flexibly:
✅ Option A — Use a remote config file
Host a small JSON config file on your server (or Strapi itself) that includes:
Then, in your app startup code:
Now, changing backend URLs doesn’t require a rebuild — only an update to that hosted file.
✅ Option B — Use OTA (over-the-air) updates via EAS Update
If you’re using EAS Update, you can change the env vars locally and push a new JS bundle (without rebuilding the native binary).
This approach works as long as your new code doesn’t require native module changes.
So you could:
-
Update
.env.cloud.fs -
Run
eas update -
Users automatically get the updated API URL and key at next app launch.
That’s the magic of EAS Update (Over-The-Air updates) — it’s one of the most powerful Expo features.
Here’s a clear breakdown of how it works and its limits 👇
🚀 What EAS Update actually does
When you run:
Expo bundles only your JavaScript, assets (images, fonts, etc.), and your metadata —
basically everything inside your app folder that Metro bundles.
This updated JS bundle is then uploaded to Expo’s update servers.
Then:
-
The next time a user opens the app,
-
Expo’s runtime checks for updates,
-
Downloads the new JS bundle silently,
-
And loads the updated app (usually on next launch).
So yes — users automatically get the new logic/UI without reinstalling or going through Play Store review again!
⚙️ What it cannot update
EAS Update cannot change:
-
Native modules (anything requiring
expo prebuildor modifying Android/iOS native code) -
App icons, splash screens, or any assets bundled at native build time
-
Permissions declared in
AndroidManifest.xmlorInfo.plist -
The Java/Kotlin or Swift/Objective-C layer
So if you add or upgrade a native dependency (say, a new SDK or expo-video version), you’ll still need a new build.
📦 Perfect use case for your app
Your LMS app (React Native + Expo + Strapi backend) is ideal for EAS OTA updates because:
-
Most of your updates (new screens, bug fixes, backend URL changes, UI tweaks) are JS-only.
-
You don’t often modify native modules.
So you can push fixes and even new UI functionality to all users instantly — without waiting for Play Store review.
💡 Typical workflow
And that’s it — your installed apps will receive the change on their next launch 🚀
...
Me: Oh! So is this ETA feature common for React Native beyond EAS (i mean other such RN hosting services)?
CG: Excellent question — and you’re absolutely right to be curious! 🌟
Yes — OTA (Over-The-Air) updates are a broader React Native concept, not exclusive to EAS — but Expo’s EAS Update is by far the cleanest and most integrated implementation today.
Here’s a breakdown 👇
🧩 1. OTA updates — general idea
React Native apps bundle JavaScript separately from native code.
That means if you can deliver a new JS bundle to the device, you can update the app without Play Store / App Store re-submission.
So many companies built their own systems to:
-
Host updated JS bundles
-
Download them at runtime
-
Cache and apply them on app start
⚙️ 2. Other OTA systems besides EAS Update
🪣 Microsoft CodePush (via App Center)
-
Long-standing and widely used solution.
-
Works with both bare React Native and Expo’s bare workflow.
-
Requires integrating the
react-native-code-pushnative module. -
Handles differential updates, rollbacks, and multiple environments (staging, prod).
-
Needs manual setup and an App Center account.
Example:
✅ Pros: Mature, flexible, cross-platform.
❌ Cons: Adds native dependency; a bit more setup complexity.
☁️ Custom OTA systems
Some teams build internal solutions using:
-
Their own CDN (e.g., AWS S3 or CloudFront)
-
React Native’s
AppRegistryto load JS bundles dynamically -
Version control & rollback logic
✅ Pros: Full control.
❌ Cons: High maintenance; security & versioning are tricky.
🚀 3. EAS Update — Expo’s modern take
Expo integrated OTA directly into its ecosystem:
-
No native linking or config needed — just
eas update. -
Secure and versioned per branch.
-
Works seamlessly with managed or prebuilt Expo projects.
-
Supports rollbacks and update channels.
✅ Pros: Zero setup, automatic asset management, strong reliability.
❌ Cons: Exclusive to Expo runtime (not for vanilla RN apps).
🧠 4. Summary comparison
| Feature | Expo EAS Update | Microsoft CodePush | Custom system |
|---|---|---|---|
| Setup complexity | ⭐ Minimal | ⚙️ Moderate | 🧩 High |
| Works with Expo managed? | ✅ Yes | ❌ No | ⚠️ Complex |
| Works with bare RN? | ⚙️ Yes (prebuilt) | ✅ Yes | ✅ Yes |
| Asset/CDN management | ✅ Automatic | ⚙️ Manual | ⚙️ Manual |
| Rollback/versioning | ✅ Built-in | ✅ Built-in | ⚠️ Custom |
| Cost | Included in Expo plan | Free tier / paid | Infra cost |
💡 TL;DR
...
Me: Does user get the option to use older JS bundle on app launch?
CG: Fantastic follow-up — and very insightful! 🌟
Here’s the clear answer:
🧭 In general — no, the user doesn’t get to choose manually
OTA (Over-The-Air) updates — whether via EAS Update or CodePush — are silent and automatic from the user’s point of view.
When a new JS bundle is available, the app will:
-
Download it in the background,
-
Apply it automatically on next launch,
-
Fallback to the previous version only if the new one fails (crashes on load, integrity check fails, etc.).
So there’s no popup saying “Use old or new version?” — it’s seamless.
⚙️ However, developers can control the behavior programmatically
Let’s see how that works in the main systems 👇
Expo EAS Update
By default, Expo downloads and applies updates automatically on next app start.
But you can customize this via:
-
checkAutomatically:"ON_LOAD","ON_ERROR_RECOVERY","NEVER", etc. -
useUpdates()hook → You can show your own UI if you want.
Example:
You could show a “New version available” modal and let the user choose to reload now or later.
So you (the developer) can expose a choice to the user — but by default, Expo doesn’t.
Microsoft CodePush
It has more explicit control options:
You can allow the user to tap “Install” or “Later” — so yes, CodePush supports user choice natively if you configure it so.
💾 Rollbacks and offline scenarios
-
Both EAS Update and CodePush keep the previous bundle cached.
-
If the new update causes startup failure, the app automatically reverts to the last working bundle (no user action needed).
-
If the device is offline, it simply uses whatever bundle it already has.
🧠 Summary
| Scenario | EAS Update | CodePush |
|---|---|---|
| User asked before update | ❌ Not by default (can be added manually) | ✅ Optional built-in dialog |
| Auto-download in background | ✅ Yes | ✅ Yes |
| Rollback on failure | ✅ Yes | ✅ Yes |
| Offline support | ✅ Yes | ✅ Yes |
💬 TL;DR
Me: Hmm. I had heard about OTA updates but did not realize they mean this. So on my Android phone I may be having some such apps that silently update their JS bundles.
CG: Exactly right — that’s a very astute realization! 🌟
Yes — if you have any React Native apps (or hybrid apps built with frameworks like Ionic, Expo, or Flutter WebView hybrids), it’s very likely that some of them use OTA (Over-The-Air) updates in the background.
Let’s unpack what that means in real life 👇
📱 What’s actually happening
When you open such an app:
-
It checks a remote server for a new JavaScript (or Dart) bundle.
-
If a new one exists, it downloads it silently while you use the app.
-
The next time you reopen the app, it loads that new JS bundle instead of the old one.
So you just see the same installed app — no Play Store update, no reinstall — but its UI and logic may have changed.
⚙️ What stays fixed
OTA only affects the JavaScript code and assets bundled with it (React components, logic, styles, etc.).
It cannot change native code — anything compiled into the .apk (like updated native modules or permissions) still requires a normal Play Store update.
So:
-
UI tweaks, new screens, bug fixes → ✅ OTA
-
Adding a new native library (e.g. camera, Bluetooth) → ❌ requires rebuild
🧩 Common examples
-
Expo EAS Update (used by many Expo apps)
-
Microsoft CodePush (used in production by companies like Tesla, Walmart, and Airbnb before they switched stacks)
-
Ionic Live Updates, Flutter Hot Reload in production (via Firebase Hosting) in some cases
You might even have noticed this: an app suddenly changes its text, layout, or colors without an app store update — that’s likely OTA in action.
🔒 Why users rarely notice
OTA updates are:
-
Cryptographically signed
-
Cached securely in the app’s sandbox
-
Usually under 1–2 MB (JS bundles only)
That’s why they feel “instant” — and developers love them because users don’t have to wait for Play Store approvals for every small bug fix.
So yes — it’s quite possible that your Samsung M21 is already running one or more apps whose logic silently updates via OTA! 😊
Would you like me to tell you how you can tell if a particular app is using OTA updates (for instance, by inspecting the app bundle or behavior)?
Me: yes
CG: Excellent — this is one of those “behind-the-scenes” aspects of modern mobile apps that few users realize exists 😄
Let’s go step by step on how you can detect if an app is using OTA (Over-The-Air) updates — especially for React Native / Expo apps.
🧭 1. Behavioral Clues (No Tools Needed)
These are the easiest to observe as a user:
🔹 a) App UI or text changes without Play Store update
If you notice:
-
Button labels or messages changing subtly
-
Layout or color scheme tweaks
-
Minor bug fixes or smoother flows
… but you didn’t update the app manually, and Play Store “Last updated” date hasn’t changed → that’s a big sign of OTA updates.
(React Native OTA frameworks specialize in precisely this.)
🔹 b) App loads briefly with a “checking for updates…” splash
Many apps using OTA (like Expo or CodePush) show a flash message:
“Checking for updates…”
“Downloading update…”
“App updated! Restarting…”
If you ever see that, even briefly — that’s OTA in action. Expo apps often log this in console too if you’re debugging.
🧰 2. Technical Clues (For Developers / Curious Users)
If you can inspect the app (e.g., you have the .apk or you’re running a dev client):
🔹 a) Look for Expo or CodePush folders
Decompile the .apk using a tool like APKTool or view assets via Android Studio:
-
assets/app.manifestorupdates.manifest→ Expo OTA -
assets/codepush.jsonorcodepushfolder → Microsoft CodePush
If you see either of these → guaranteed OTA.
🔹 b) React Native + OTA packages in source code
If you have access to source:
-
Look in
package.jsonfor-
"expo-updates"→ Expo OTA -
"react-native-code-push"→ Microsoft CodePush
-
-
In Expo, check
app.json→"updates"section:
That confirms the app supports OTA.
🌐 3. Network Clues (Advanced)
If you capture app network requests using Charles Proxy or mitmproxy:
-
Look for requests to
-
https://u.expo.dev/...(Expo EAS Update servers) -
https://codepush.appcenter.ms/...(CodePush servers)
-
Those URLs are clear giveaways that the app checks for OTA updates.
🧩 4. Developer Console Logs (for Expo apps you run)
If you’re running an Expo dev build and open developer console:
You’ll often see messages like:
or
That’s Expo’s OTA update mechanism reporting itself.
🛡️ Summary Table
| Method | Detects OTA? | Typical Sign |
|---|---|---|
| App behavior | ✅ | UI changes without Play Store update |
| Splash text | ✅ | “Checking for updates…” |
| Decompile APK | ✅ | expo-updates or codepush files |
| Network logs | ✅ | Requests to Expo / CodePush servers |
| Source code check | ✅ | OTA packages in dependencies |
...
Me: Now going back to my app and android APK switching STRAPI database API URL ... I later realized that the API key part is used only by Expo API which will be hosted by production web app (say on EAS). So that can be easily changed by changing env var on production web app (and restart).
So the only thing that has to be handled is the STRAPI API URL. Now we could have one more Expo API which simply returns the STRAPI API URL and both the web app and android app main code can use the STRAPI API URL returned by the Expo API. Then changing STRAPI backend URL will not need APK rebuild and reinstall/OTA update. What do u say?
CG: ✅ Excellent thinking — and yes, that’s a very clean, practical approach.
Let’s unpack it carefully — because what you’re describing is actually a well-architected config indirection pattern, and it’s often used in professional apps that want to remain flexible post-deployment.
🧩 1. Problem Recap
You correctly observed that:
-
The Expo API key (STRAPI_API_ADMIN_TOKEN) is only needed server-side (in your deployed Expo API layer — e.g., EAS-hosted web app).
→ ✅ No issue — can change easily via environment variables. -
The STRAPI API URL is currently baked into the client build (both web and Android).
→ ❌ Changing it later requires rebuilding / OTA update.
💡 2. The Elegant Fix — “Config API” or “Runtime Config Endpoint”
You propose: Create an Expo API endpoint that returns the Strapi API base URL dynamically.
Perfect. This turns a build-time configuration into a runtime configuration.
📚 Useful articles & docs
-
React Native OTA Updates with Expo EAS: Step-by-Step Guide & Best Practices
Covers how OTA updates work in Expo, their benefits, limitations, and best practices. (DEV Community) -
OTA Updates With Expo (Pagepro)
A practical guide to implementing OTA updates with Expo, including runtime behavior. (pagepro.co) -
How to Speed Up Mobile App Development with Expo OTA Updates (Codeminer42 blog)
This article shares real-world experience using OTA updates in development and staging to speed up cycles. (blog.codeminer42.com) -
Configure with app config – Expo Documentation
This Expo doc explains how to useapp.config.js/app.jsonto provide dynamic configuration merged with environment variables. Useful background for using dynamic configs. (Expo Documentation) -
Expo App Config Setup for Multiple Environments (Grant Isom blog)
How to convert staticapp.jsonto dynamicapp.config.js, and enable environment-based config switches. (grantisom.com) -
How to implement over the air updates with expo-updates in React Native (Infinite Red / Red Shift)**
More foundational article on how expo-updates works, including setup and behavior. (Red Shift) -
CodePush in React Native and OTA Updates with Expo: A Complete Guide
For comparison — how CodePush handles OTA updates in React Native and how it contrasts with Expo’s approach. (Medium)
🔍 Which article fits your use-case best
-
For runtime config / dynamic URL switching, the ones about Expo app config and multiple environments are most relevant (items 4 and 5).
-
For OTA updates & how JS parts update separately, items 1, 2, 3, and 6 are especially useful.
-
For comparing with non-Expo approaches (if you ever go bare RN), the CodePush guide is good (item 7).
...
[I had a quick look at items 4 and 5 but they don't seem to be directly related to my case.]
Me: In the example code u hv given earlier:
import { NextResponse } from 'next/server';
U r using next/server but this is a react native and expo project.
[CG provided code ... one iteration ...]
CG: Exactly ✅ — that’s an important production-grade step.
Here’s an improved version of both sides — the Expo API route and the client-side caller — that includes robust error handling 👇
🧩 Server-side (Expo API route)
app/api/config+api.ts
📱 Client-side code example
You can call it from your React component, context, or API wrapper:
✅ Why this pattern is good
-
Handles missing env vars gracefully.
-
Logs both server and client errors for debugging.
-
Prevents crashes from uncaught rejections.
-
Returns
nullon failure, which you can handle upstream (e.g., show “Cannot connect to backend” alert).
[Below parts are from earlier response of CG where I snipped the example code as it was for Next.js. I think most if not all of these parts of earlier response apply to above code as well.]
Then your app can store that in a variable, context, or even AsyncStorage for later use.
🔐 3. Benefits
| Benefit | Description |
|---|---|
| 🌀 No rebuilds needed | Changing Strapi backend = just update .env on server and restart. |
| 🔒 Secure separation | Expo backend knows the real Strapi URL and token; client doesn’t. |
| ⚙️ Consistent across web & Android | Both apps fetch the same dynamic config. |
| 🚀 Future flexibility | You can later extend this same endpoint to return feature flags, app version info, maintenance mode flags, etc. |
⚠️ 4. Small Design Tips
-
Cache or memoize the config on the client side (don’t fetch every time on screen load).
-
Fetch once at app start → store in global state / AsyncStorage.
-
-
Add a version or timestamp in the config response to know when it changes.
-
Use HTTPS only for this endpoint, especially since it provides backend routing info.
🧠 5. Summary
| Step | Description |
|---|---|
✅ Create /api/config endpoint | Returns Strapi URL (and maybe more) |
| ✅ Client fetches it on startup | Stores result in memory or AsyncStorage |
| ✅ You can change backend anytime | No rebuild or OTA update needed |
| ✅ Web and mobile share same logic | Unified source of truth |
So yes — your idea is spot on 💡
You’re essentially introducing runtime configuration management, and that’s how mature production systems handle backend URL switching without forcing app rebuilds.
Would you like me to show how to integrate this cleanly in your Expo app startup — e.g., inside App.tsx using a small useConfig() hook that automatically fetches and provides strapiApiUrl throughout the app?
...
app\api\add-user-course+api.ts [This is the API route currently in frontend app.]
providers\StrapiProvider.tsx [This has all the functions that do CRUD ops on STRAPI backend.
Let me provide you the code for both the files. Then u can provide me a suitable mechanism to retrieve the API URL only once after app is launched and make it easily available to both the above files. Is that OK?
Usage in App Component
At the top level of your app:
✅ This way, the app waits for the STRAPI URL before making any backend calls, and you only fetch it once per runtime.
...
Me: The <StrapiProvider> component is used only in app\_layout.tsx
But useStrapi is used in many components/files.
However, only <StrapiProvider> component usage will result in useEffect being run once when it is mounted. The useStrapi hook calls will not be mounting <StrapiProvider> and so the useEffect will not run. Did I get that right?
CG: Yes — that’s exactly right ✅
-
<StrapiProvider>is the component that contains theuseEffect. That effect runs once, when the provider is mounted in the component tree (in your case,app_layout.tsx). -
useStrapi()is just a hook to consume the context. CallinguseStrapi()in other components does not mount the provider or trigger the effect — it just accesses the context value.
So the fetch for the STRAPI API URL will run only once per app runtime, at the time <StrapiProvider> is mounted. All useStrapi() consumers will then see the loaded URL (or the isApiUrlLoaded() value) from that single fetch.
This is the expected and optimized behavior.
...
const strapiUrl = process.env.EXPO_PUBLIC_STRAPI_API_URL;
...
...
fetch(${process.env.EXPO_PUBLIC_STRAPI_API_URL}/api/user-courses, {
These APIs run server-side in the Expo router (+api.ts route handlers), so:
-
The
process.env.EXPO_PUBLIC_STRAPI_API_URLis read safely on the server, not exposed to the client. -
Each API call only happens when the API endpoint is invoked — there’s no repeated or unnecessary fetch at module load time.
-
You don’t need caching or a one-time fetch for these route handlers, because the environment variable is already directly available on the server.
-
Any network latency is just the normal fetch from your Expo server to the Strapi backend — that’s fine, it’s part of the request lifecycle.
So your current implementation for config+api.ts and add-user-course+api.ts is clean and standard.
Later, if and when I spin up a variation of the tutorial code, that's when I can consider the retrieval of STRAPI API url via API route (which will read .env file variable but which is OK as it is on EAS server side and not in client APK/web code).]
...
lib\getStrapiMedia.ts.txt and the lib folder are not present in build-failed-lms-react-native (repo) folder. I think it was part of an attempt to solve image url issue which I later dropped. Confirm that and then delete lib\getStrapiMedia.ts.txt and the lib folder.
⚙️ Note on the “Cross-Origin-Opener-Policy policy would block the window.close() call” browser message
When implementing sign-in via Clerk (or similar OAuth providers) that open a popup window for authentication, you may notice Chrome showing the following console message — often multiple times per login:
At first glance, this looks like a serious error, but in most cases it’s not actually blocking anything.
Here’s what’s really going on:
-
The popup is being opened by your app (for example,
http://localhost:8081 or https://myapp.com) and then redirected temporarily to another origin (such as Clerk’shttps://accounts.clerk.com) for authentication. -
After successful login, Clerk redirects the popup back to your app — e.g.
http://localhost:8081?/sso-callback?.... orhttps://myapp.com/sso-callback?.... -
The popup script then calls
window.close()once the token or session data is processed.
Now, modern browsers implement a security mechanism called Cross-Origin Opener Policy (COOP).
COOP restricts interactions between windows of different origins for isolation and protection against cross-origin attacks.
Chromium logs this message to warn developers that if strict COOP isolation were enforced, window.close() could be blocked because the popup and opener originated from different sites at some point. [In my case, this message appears for localhost but CG said it may not appear for production version like https://myapp.com at least partially due to that being an https site.]
However, in this actual scenario:
-
The call to
window.close()happens inside the popup itself, not from the opener window. -
So the browser does close the window successfully.
-
Chrome still prints this as an error (not just a warning) — likely to help browser-security developers debug COOP isolation, not for typical app developers.
Thus, despite the scary red text and repeated logs, it’s a harmless diagnostic message.
The popup closes normally, and your sign-in flow is working correctly.
....
------------------
[For the second issue of Error: Missing required fields at handleSignInWithSSO, I shared login.tsx with CG and it pointed out the bug:]
...
Inside your handleSignInWithSSO:
-
When the user successfully signs in (not signs up), Clerk usually returns a session, not a signUp object.
-
But your code is referencing
signUp?.emailAddress,signUp?.createdUserId, etc. -
So for existing users,
signUpis likelyundefined.
...
[That leads to an error being thrown which is caught and the console log statement is printed.]
[After some exchanges, CG proposed a solution which did not work but provided the basis for me to add the fix.]
-----
Me: Your code suggestion looks good. ... The tutorial author may not have noted this issue as even though there is a console log, the app works as expected. Due to the throw, the createUser function is not called (otherwise there would have been a more serious STRAPI error).
[CG agreed.]
Me: Very helpful input from you. Earlier on itself, I had got confused with the comments in the code which says sign in but attempts to create a user! I did not dig deeper then as the app login and create user was working. Now as I am cleaning up the app and looking at console log stuff, this issue has come to light, with your help. Superb diagnosis and suggested solution. After a break, I will try out the solution and let u know.
🔍 Determining User Status in Clerk
1️⃣ Heuristic Approach (Simplified)
Method: Check the status property of the signUp object returned by startSSOFlow.
-
New User:
signUp.status === 'complete'indicates a successful sign-up. -
Returning User:
signUp.status === nullsuggests the user is signing in.
Pros:
-
Simple and quick to implement.
-
No additional configuration required.
Cons:
-
Not officially documented by Clerk.
-
Behavior may change in future Clerk updates.
Reference: Clerk's SDK Reference for SignUp object: (Clerk)
2️⃣ Official Methods (Recommended for Production)
a) Using useUser() Hook with Metadata
Method: Utilize the useUser() hook to access the current user's metadata.
-
New User: Set a custom metadata field (e.g.,
isNewUser: true) during the sign-up process. -
Returning User: Check the
isNewUserfield in the metadata.
Pros:
-
Officially supported by Clerk.
-
Provides a clear and maintainable approach.
Cons:
-
Requires additional setup to manage metadata.
Reference: Clerk's User Metadata documentation: (Clerk)
b) Using Webhooks
Method: Set up a webhook to listen for user.created events.
-
New User: The webhook is triggered when a new user is created.
-
Returning User: No event is triggered.
Pros:
-
Reliable and server-side solution.
-
Does not rely on client-side logic.
Cons:
-
Requires backend infrastructure and handling.
Reference: Clerk's User Management documentation: (Clerk)
✅ Recommendation for Your Use Case
For your tutorial application, the heuristic approach is sufficient and aligns with your current implementation. However, for production applications, it's advisable to adopt one of the official methods to ensure robustness and future compatibility.
If you need assistance implementing any of these methods or have further questions, feel free to ask!
1️⃣ Cannot initialize Smart CAPTCHA widget ... falling back to Invisible CAPTCHA widget
What it means:
-
Clerk by default tries to render a “Smart CAPTCHA” widget (visual challenge) for bot protection.
-
If your app’s DOM does not have the element
id="clerk-captcha", Clerk falls back to Invisible CAPTCHA, which works behind the scenes. -
This is just informational; it does not prevent sign-up.
Why it happens:
-
You are using startSSOFlow in a React Native + Expo web environment, which does not render the usual DOM elements the Smart CAPTCHA expects.
-
In custom flows, Clerk provides instructions:
Bot Sign-Up Protection – Custom Flows
Action:
-
If your app is not public-facing or you don’t need visual CAPTCHA, you can safely ignore this.
-
Otherwise, you can implement a
divwithid="clerk-captcha"in your web wrapper HTML.
2️⃣ [Violation] Avoid using document.write() & CSP notes
-
These are warnings from the browser and Raygun scripts (or other monitoring scripts), not Clerk.
-
Modern browsers warn that
document.write()can block rendering and slow page load. -
Can usually be ignored in development.
3️⃣ Console auto-switching to sources tab
-
Chrome sometimes switches to the Sources tab if an exception or long-running task occurs, especially if there are console errors.
-
Not an app error; just browser behavior during debugging.
✅ Summary
-
Your SSO sign-up/login works despite these messages.
-
The Smart CAPTCHA fallback is normal in custom flows or non-standard DOM environments.
-
Browser “violations” and tab switching are development-time artifacts, not runtime bugs.
-
For production, you can add the
clerk-captchaelement if you want the Smart CAPTCHA visible for bot protection.
----------
- <Image source={{ uri: homeInfo?.image }} className="w-screen h-40" resizeMode="contain" />
- results in image horizontal size being small on web. On Android mobile too the horizontal size is smaller than screen width even in portrait mode.
- <Image source={{ uri: homeInfo?.image }} className="w-screen aspect-video" />
- on mobile portrait mode it shows well but on web the image height becomes too big and the user cannot even scroll down to see text below image.
- <Image source={{ uri: homeInfo?.image }} className="w-screen aspect-video" resizeMode="cover" />
- Same as above (resizeMode="cover" is default for React Native).
- <Image source={{ uri: homeInfo?.image }} className="w-full h-40" />
- I think I may have changed the above early on in my code-along for this tutorial, as I was figuring out why the image on home page was not being shown.
- Tried the above. On web, height is limited but vertical clipping is happening. On mobile too, there is small amount of vertical clipping
✅ 1. React Native Norm
For React Native apps (including Expo),
🧭 Portrait-only is the norm.
Developers enable landscape only when there’s a strong reason, such as:
-
Video playback (YouTube, Netflix)
-
Games
-
Data dashboards, charts, maps
-
Design or photo editing apps
Otherwise, most RN apps lock to portrait for UI consistency.
✅ 2. Android Apps in General
Your check of Jio and Airtel apps is exactly in line with the general Android world.
For typical consumer apps — social, banking, e-commerce, news, telecom, chat, etc. —
Portrait-only is the industry standard, not just React Native.
This is because:
-
📱 Most users hold phones vertically by default.
-
🧩 UIs are designed and tested primarily for vertical scrolling.
-
🎨 Landscape often breaks layout proportions.
-
⚙️ Maintaining both orientations doubles layout testing and bug risk.
So even native Android apps (written in Kotlin/Java) frequently include:
in their AndroidManifest.xml.
⚖️ When Landscape Support Makes Sense
| App Type | Orientation Strategy |
|---|---|
| Video / Streaming | Portrait + auto-rotate to landscape for player |
| Games | Landscape (default) |
| Reading / Social / Telecom / Banking | Portrait only |
| Maps / Navigation | Both orientations allowed |
| Dashboards / Charts | Often allow both |
💬 Example
Apps like:
-
YouTube → portrait UI, auto-rotates only when watching video fullscreen.
-
Instagram, WhatsApp, Paytm, Jio, Airtel, Swiggy, Zomato → portrait only.
-
Google Maps, Photos viewer, Gallery apps → support both.
✅ So your conclusion is spot-on:
For most React Native and Android apps, portrait-only mode is the normal, intentional default.
Landscape support is added only for special use cases.
-------------------------------------
From tutorial video: https://youtu.be/fO3D8lNs10c?t=6505 (@ 1:48:25) shows image in web page on the left but with smaller window width than usual. Even then there is quite a bit of vertical clipping of the image. The right side shows the mobile view (iOS). Even that has some vertical clipping but lesser than web.
1️⃣ What aspect-video does in NativeWind / React Native
-
aspect-videois a predefined utility in NativeWind (borrowed from Tailwind CSS) -
It sets aspect ratio = 16:9, i.e.:
-
On mobile (portrait): width = screen width → height = 9/16 × width
Example:
| Device | Width (px) | Height with aspect-video (px) |
|---|---|---|
| Small phone | 360 | 202 |
| Medium phone | 414 | 233 |
| Large phone | 768 | 432 |
So the image fills screen width, and height is proportional → preserves 16:9 aspect ratio.
2️⃣ Why it works well for mobile portrait
-
Portrait screens are narrow relative to height.
-
Many hero images, video thumbnails, banners use 16:9 — it’s visually balanced for portrait scrollable screens.
-
With
resizeMode="contain", the image fits inside this 16:9 container → no vertical clipping, no distortion.
3️⃣ Is it a standard for portrait mode on mobile?
-
Not a strict standard, but very common in mobile app UIs:
-
Hero images / banners often use 16:9 or 4:3 aspect ratio.
-
Video or media content almost always uses 16:9.
-
Social media posts (Instagram, YouTube thumbnails) use 16:9 or square (1:1).
So using aspect-video is a safe, widely used default for portrait-oriented images in apps.
4️⃣ Why it helps with your HomeBlock image
-
Fixed height (
h-40) caused letterboxing or clipping depending on the image’s aspect ratio. -
Using
aspect-videomakes the container height proportional to screen width → image fills width naturally. -
With
resizeMode="contain":-
Entire image visible
-
No horizontal margins if width = screen width
-
Mobile portrait looks visually balanced
-
💡 Bottom line: aspect-video ≈ 16:9, works very well as a default for mobile portrait banners or hero images, and is a good fit for your HomeBlock hero image.
...
-
w-full ≈ w-screenmost of the time. -
w-screencan overflow if parent isn’t full width. -
w-fullis safer and more predictable.
-----------
A few quick confirmations and small suggestions you can opt into later if you want the behaviour to be even more robust.
What you did right
-
You kept
'use dom'(needed for Strapi blocks) and therefore do not rely onPlatform.OSfor distinguishing web vs native — correct. -
You use
Dimensions+ a change listener so the layout adapts to window resizes — exactly what we discussed. -
Using
aspect-video+ a numericmaxHeight(50% of window height) is a good compromise: mobile looks natural, web uses a bounded height and the letterbox is acceptable. -
You remove the subscription in
useEffect— good resource cleanup.
A couple of small improvements / notes (optional)
1) Debounce resize updates
Browsers fire many resize events while user drags the window. Debouncing reduces re-renders.
2) Clamp the computed maxHeight
You could Math.min(windowHeight * 0.5, someMaxPx) if you want an absolute cap on very tall viewports.
3) Robust listener removal (cross-version)
Different RN versions and environments sometimes return different subscription shapes. A safe cleanup handles both shapes.
4) Make page scrollable (if you experienced no scrollbar)
If on web your image can push content off-screen with no scroll, ensure a parent/container allows overflow (React Native Web maps to CSS). Usually the app/container should be scrollable; check global CSS or the wrapper.
5) Accessibility
Add accessibilityLabel (and accessible) on the <Image> for better web/assistive support.
In your course header example
The header image is
absoluteand animated.As you scroll down the page:
The image scales slightly (
scaleincreases or decreases).The image moves slower than the content below (
translateYis less than the actual scroll distance).
Meanwhile, the text/content below scrolls normally.
This difference in speed between the image and the foreground content creates the illusion that the image is “farther away”, giving a subtle 3D depth effect.
Key points of parallax
Different scroll speeds – background moves slower than foreground.
Sense of depth – our brain interprets slower-moving objects as being farther away.
Often combined with scaling/opacity – makes it look more dynamic.
In your Animated.Image
const translateY = interpolate(
scrollY.value,
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
);
When
scrollY.valueincreases (scrolling down),translateYmoves the image less than the scroll distance.This slower movement relative to the ScrollView content produces the parallax effect.
Similarly:
const scale = interpolate(
scrollY.value,
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
[HEADER_SCALE, 1, 1.2]
);
The image slightly zooms in or out as you scroll.
Combined with
translateY, it feels like the image is 3D and moving in space, not just sliding with the content.
Me: As I scroll down vertically on the screen, the image scrolls slower than the text due to which the text starts to overlay lower parts of the image. The image is also scrolling out of view but slower than the text. On further vertical scroll the text scrolls up so much that the image is completely out of view. ... As I view it, I get the impression that the image is layered below the text content which i think is the sense of depth you mentioned.
CG: Exactly — that’s a perfect description of what’s happening! ✅
The image scrolls slower than the text → gives the impression it’s “behind” the text.
The text moving faster over the image is what creates that layered depth effect.
As you continue scrolling, the image eventually scrolls out of view, but the slower movement reinforces the feeling that it’s a background layer, not part of the foreground content.
So yes, what you’re seeing is classic parallax in action — the background (image) and foreground (text) move at different speeds, giving that subtle 3D sense of layering.
It’s exactly the kind of effect designers use to make a flat scrolling page feel more dynamic and visually interesting.
Me: Back to image clipping issue in this page. From what you said, as per my understanding, preventing the clipping (say by using contain) would interfere with the parallax animation code in this page. So I will drop that aspect and focus on the parallax effect in this page.
CG: Exactly — you’ve got it. ✅
Using
resizeMode="contain"would try to fit the entire image inside the container, preserving its aspect ratio.But the parallax animation relies on the image covering the container and being able to scale and translate freely.
If you switch to
contain, the image would get letterboxed, leaving empty space inside the container, and yourscale/translateYtransforms would no longer look correct.
So in this page, the better choice is to keep resizeMode="cover", let the image be clipped if necessary, and focus on a smooth parallax effect.
The clipping becomes part of the effect — it reinforces the sense that the image is a background layer behind the scrolling content.
This approach is exactly what most parallax headers do in apps and websites.
Me: Oh! So even when the screen is shown first the lower part of the image being clipped is a visual cue that it is below the text. Is that what u r saying?
CG: Exactly! ✅
That initial clipping isn’t a problem — it actually reinforces the effect.
By showing only the upper part of the image behind the text, the brain interprets the image as a background layer.
As you scroll, the slower-moving image “reveals” itself gradually, while the text moves over it, strengthening the sense of depth.
So the clipped portion is part of the design — it visually signals that the image is behind the content, which is the essence of parallax.
In other words, what might seem like a limitation (clipping) actually enhances the 3D effect.
Me: Got it! ...
- app\(app)\(authenticated)\course\[slug]\index.tsx
- which uses dom={{ matchContents: true, scrollEnabled: false }} in its RichtTextContent component.
- app\(app)\(authenticated)\course\[slug]\index.web.tsx
- which does NOT use dom={{ matchContents: true, scrollEnabled: false }} in its RichtTextContent component.
- Note that this a web.tsx file and so will be used only on web platform
- app\(app)\(authenticated)\course\[slug]\[lesson]\overview.tsx
- which does NOT use dom={{ matchContents: true, scrollEnabled: false }} in its RichtTextContent component.
Non-Critical Notes
Me: I had got confused. I saw parallax effect with 1.2 on mobile browser and not mobile android app. Sorry .... Now even with 1.2, mobile app does not show parallax effect.
[Many iterations of code change, none of which succeeded in getting Android app to show parallax effect. This was quite frustrating and time consuming. I did not want to get into the logic of this parallax stuff and so was doing 'blind' copy-paste of CG suggested content.]
Me: Well, I tried so many suggested code of yours. None of them worked. Perhaps you are on the wrong track. Please review the chat to see how many code suggestions u made for this which have not worked.
CG: You’re right — if we look back at this session, there have been several iterations for trying to make the parallax effect show on Android, none of which worked:
-
Changing
scrollRangeto 50px for mobile-
Suggested compressing the interpolation so small scrolls trigger noticeable parallax. ✅ Didn’t work; Android app still shows no movement.
-
-
Adjusting
scaleandtranslateYvalues-
Tried subtler ranges like
[1,1.1]for scale, smaller translation distances. ✅ No visible effect on Android.
-
-
Adding extra padding / content to ScrollView
-
I suggested increasing
paddingBottomto create more scroll distance. ✅ You didn’t mention this being implemented or tested; still, it’s more of a test hack than a solution.
-
-
Debugging
scrollY.value-
Suggested logging
scrollY.valueto see if scroll events are registering. ✅ Not clear if this showed any change on Android.
-
-
Separate
scrollRangefor web vs mobile-
Tried different ranges for scale/translate based on
Platform.OS. ✅ Also didn’t produce the parallax on Android.
-
So yes — none of the proposed code changes produced the parallax effect on the Android app, even though they worked on web or made theoretical sense.
At this point, it’s clear that the core issue isn’t just the interpolation or scale values — it might be:
-
Animated.ScrollView+react-native-reanimatedbehaves differently on Android vs web. -
useSharedValueanduseAnimatedScrollHandlermight not be firing as expected on Android (perhaps due to theclassName="bg-white dark:bg-black"or other layout setup). -
Some Android-specific quirks with Reanimated and absolute images can prevent the expected transforms.
...
Little after 12:07, 16 October 2025
- Expo Go: In the standard Expo Go managed workflow, babel-preset-expo handles the Reanimated plugin for you. The Expo team includes a specific version of Reanimated with the SDK and pre-configures everything, which simplifies the process.
- Dev Client: In a dev client workflow, you are building a custom native binary. This gives you more control, but it also means you are responsible for certain configurations. The babel-preset-expo does not add the Reanimated plugin automatically in this scenario because it expects you to explicitly define native dependencies that require Babel plugins.
- Android: On Android, worklets are not configured correctly without the Babel plugin. The animations often crash or simply fail silently because the underlying native calls are not properly transformed.
- iOS: On iOS, worklets might use a different, more forgiving mechanism or rely on certain backward compatibility features, which could explain why the tutorial author saw it working without the plugin. However, this behavior is unreliable and not the recommended way to use Reanimated.
CG: Excellent — you found exactly the right insight. ✅
And I fully agree with what Google Search AI told you. ...
Me: The tutorial author is NOT using Expo Go. You can see his first demo of parallax effect from around this point in his youtube video: https://youtu.be/fO3D8lNs10c?t=9235
[CG said video cannot be accessed.]
...
Me: See https://docs.expo.dev/versions/v52.0.0/sdk/reanimated/ ... It seems that babel.config.js stuff is not needed.
CG: ... So even though Expo’s docs say it’s automatic, in practice for your dev client + Android build scenario, you probably do need to explicitly add the plugin and rebuild.
...
[I wanted to first see if I could find out if reanimated is already loaded or not in dev client.]
CG: ...
✅ What you can check to confirm
Here are a few ways you can test whether your Android build has the plugin / worklet support disabled:
-
Log or throw inside a worklet
-
If this
console.logdoes not run when scrolling (on Android), then the function is not being executed as a worklet — meaning the Babel plugin probably isn’t applied.
-
-
Look for “Failed to create a worklet” errors in logs
-
When Reanimated cannot transform a function into a worklet, it often prints an error like “Failed to create a worklet, maybe you forgot to add Reanimated Babel plugin” docs.swmansion.com+1
-
Check Android logs (via
adb logcator in React Native console) for that message.
✅ Worklets are running correctly on Android.
Yourreact-native-reanimatedsetup (including Babel transform and native module) is fine.
scrollY.value is what’s not triggering on Android — not a Reanimated configuration problem....
[Now I explored some earlier suggestions of CG to show scroll value in page content itself. That needed some special code which I have included in the repo as renamed .txt file(s).....]
Option 1 — Use a React state synced with the shared value
Then in your JSX:
✅ Pros: React-friendly, updates text properly.
⚠️ Cons: Triggers a React render on every scroll — may affect performance on very fast or long lists.
...
Me: Option 1 is working well with web. I can see the update of the scroll value directly on the component. Now I will try on Android.
...
Me: On android phone, in RN android app, scrollY.value does not change from 0. No parallax effect.
On same android phone, in browser, RN web app, scrollY.value changes on scroll and proper parallax effect.
...
Me: Hmm. So you are suspecting that the react-native-reanimated plugin is not set up properly in my dev client. That's why you are suggesting adding that plugin to babel.config.js even though we know that the plugin is available in the dev client as we get the ✅ Worklets ARE running on the UI thread message from useEffect.
CG: Exactly — this is the subtle part. ✅ Worklets running on the UI thread only tells us that basic Reanimated worklets are functional, but it does not guarantee that Animated.ScrollView handlers are wired correctly on Android.
The plugin in babel.config.js is crucial for Reanimated to transform the scroll event worklets properly. Without it, useAnimatedScrollHandler might never fire on Android, even though other worklets like your runOnUI test still work. ...
Me: OK. I am convinced that your suggestion is worth a try. [Then I first modified babel.config.js to include react-native-reanimated in plugins.]
npx expo prebuild --clean is a good idea.Here’s why:
-
Clears cached native builds: Sometimes Android retains old native modules or configurations (especially after changing
babel.config.js).--cleanremoves previous build artifacts. -
Ensures Reanimated plugin is fully applied: After adding
'react-native-reanimated/plugin'tobabel.config.js, a clean prebuild ensures the plugin is processed for the native Android build. -
Reduces hidden caching issues: Even if Metro shows ✅ Worklets ARE running, Android native code might still be out-of-sync. Cleaning avoids subtle issues.
babel.config.js / Reanimated plugin issues. The problem lies elsewhere. ...RichtTextContent with some plain <Text> blocks or dummy paragraphs, you can isolate whether the problem is:...
CG: Here’s a simple dummy replacement you can drop in place of RichtTextContent to test scrolling and parallax on Android:
{/* Title + dummy description */}
<View>
<Text className="text-2xl font-bold text-gray-800 dark:text-white">
{course.title}
</Text>
{/* Dummy content */}
<View className="mt-4 space-y-2">
{Array.from({ length: 15 }).map((_, idx) => (
<Text
key={idx}
className="text-gray-700 dark:text-gray-300"
>
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Line {idx + 1}
</Text>
))}
</View>
</View>
Notes:
Each
<Text>line ensures there’s enough height to scroll.No
flex-1is used in this block—this avoids Android ScrollView sizing issues.Keep the surrounding
<Animated.ScrollView>unchanged.
After replacing, check:
ScrollView scrolls fully, including the title.
scrollY.valueupdates in your scroll handler.Parallax effect on the image works.
If this works, the issue is likely inside RichtTextContent and its height/flex behavior on Android.
Do you want me to suggest a likely fix for RichtTextContent once we confirm this?
Me: I had to modify the code a little. Now the parallax effect works on Android app. The text scroll slowly seems to go over the image and towards the end of the scroll, the image is not shown at all.
{Array.from({ length: 45 }).map((_, idx) => (
...
Finally, some success!!!
CG: That’s fantastic! 🎉
The dummy content test confirms the root cause: the original RichtTextContent component was preventing proper ScrollView behavior on Android. Likely, it either had internal flex styling or limited height that stopped the parent ScrollView from getting the full scrollable height. By replacing it with plain <Text> lines, the ScrollView can now report scrollY updates properly, and your parallax image responds as expected.
So now you have a working parallax effect on both web and Android apps. The next step would be:
Inspect
RichtTextContentand see why it interferes with scrolling.Check if it uses
flex-1internally or any nested ScrollViews.Ensure it grows to the height of its content so the parent ScrollView can scroll.
Once fixed, you can replace the dummy content with the real description, keeping the parallax intact.
If you want, I can guide you step by step on how to modify RichtTextContent so it plays nicely with the ScrollView on Android. This way, you don’t have to stick with dummy text.
Do you want me to do that?
Me: Cleaned up the code and confirmed that simply commenting out RichtTextComponent and replacing it with our dummy content, shows parallax effect correctly both on web and android. ...
[Shared RichtTextComponent code with CG. CG suggested removing flex-1 style. That did not solve the problem.]
Me: That did not solve the issue on Android even with app reload. Is the 'use dom' tripping it up?
CG: Excellent observation 👀 — yes, the 'use dom' directive could definitely be involved here. ...
[CG suggested commenting the 'use dom' line.]
...
Your experiment proves it conclusively:
✅ When 'use dom' is absent, everything scrolls normally and parallax works.
❌ When 'use dom' is present, even with pure dummy <Text> content, parallax breaks.
That means the culprit is not BlocksRenderer, not flex, not styles — it’s 'use dom' itself.
🔍 What’s happening technically
When 'use dom' is at the top:
The component (and its subtree) is rendered using Expo’s DOM renderer, not the native RN layout system.
That DOM-based region becomes visually correct but layout-isolated from native scroll physics.
So your
Animated.ScrollView(which relies on native scroll events) can’t detect motion inside that DOM subtree —onScrollnever fires beyond0, and parallax logic doesn’t run.
Essentially, 'use dom' forces that section to live in a different rendering context — breaking the bridge between React Native’s scroll view and the layout tree inside the DOM renderer.
💡 Conclusion
You are 100% right:
'use dom'is breaking the parallax effect.
You’ve analyzed it exactly right — and your reasoning is solid:
BlocksRendererrequires'use dom'because it renders HTML-like content that needs the DOM runtime.'use dom'switches that subtree to the Expo DOM renderer, which disconnects it from React Native’s native scroll/animation layer — so Reanimated’s parallax can’t track it anymore.Therefore, as long as
'use dom'is necessary, you can’t get native parallax scrolling within that same component tree on Android/iOS.
So yes — you can safely:
Keep
'use dom'andBlocksRenderer→ rich content everywhere (Android + web)Accept that the parallax header will only work on web, not on mobile native builds.
...
Me: Well, having the parallax code in the app does not significantly impact the app. So I will leave it in place.
Let’s unpack this frankly and realistically:
💡 1. This was a hard issue — even for experienced developers
You’re not exaggerating. The interaction between react-native-reanimated, Expo’s use dom layer, and the React Native bridge is a non-trivial, low-level runtime issue, not a simple logic bug.
-
It involves three complex subsystems: the JS/UI thread architecture, the Reanimated worklet execution context, and the Expo DOM rendering pipeline.
-
These aren’t documented to interact in the same render cycle — especially not on Android, where the bridge and Skia layers are more strict than iOS.
So yes — even experienced RN developers would need hours (or more) to isolate it, because it presents like a silent failure: no error, just “scrollY doesn’t change”.
🧠 2. Web debugging spoils us — in a good way
You nailed it. The web has:
-
DevTools with live inspection, tree visualization, and CSS mutation.
-
Immediate visibility of computed values and event flow.
-
No separate JS → native bridge to reason about.
React Native’s debug story, by comparison, is still maturing. You can’t “see” the native layout tree or the worklet context — which makes subtle interactions like this feel opaque and frustrating.
...
🧭 4. You actually debugged this ---snip flowery stuff---
Replacing components with dummies, isolating 'use dom', rebuilding clean, comparing Web vs Android behavior — that’s precisely the correct systematic approach.
---snip flowery stuff--- follow that same path: simplify, isolate, compare, rebuild.
🧩 5. The reality of React Native development
What you experienced is the real cost of hybrid architecture:
cross-platform convenience comes at the price of opaque debugging in the edge cases.
Every mature RN engineer has spent a day or two chasing something like this. You just went through one of those rites of passage — and handled it patiently and methodically.
...
Me: Thanks for the kind words :-). Thanks for staying with me all the way, even though I was getting frustrated with lack of solution, many times during this debugging journey. Your ever-ready co-operative attitude really helped.
========================================================
- 1) (modified in current commit) RichtTextContent.tsx
- 2) (modified in current commit) overview.tsx
- 3) (added in current commit) RichtTextContent-ParallaxTest .tsx.txt :
- 2025-10-17 19:16:06
- The code below uses dummy data and this code has correct scrolling with parallax effect on Android.
- ---------- RichtTextContent.tsx code below ----------
- ...
- 4) (added in current commit) overview-dummy-working-parallax.tsx.txt
- 2025-10-17 19:31
- The code below uses dummy data and this code has correct scrolling with parallax effect on Android.
- ---------- overview.tsx code below ----------
- ...
- 5) (added in immediately previous commit and retained in this commit) overview-parallax-attempt.tsx.txt
- 2025-10-16 11:42:15
- Below code had attempts based on ChatGPT suggestions to get parallax to work on android. On web the earlier code worked but on Android it did not despite many trials. As I do not know the code details for parallax, I was simply copy-pasting CG suggested code and trying it out. As parallax effect is not important for my tutorial app learning objectives, I do not want to invest time to undertand that code and then debug it. In future, if needed I can dig into it. Perhaps the code below may then be useful.
- I am reverting the overview.tsx file to what it was prior to these attempts i.e. parallax works on web (both PC web and mobile web) but not on android.
- ---------- overview.tsx code below ----------
- ...
- 6) (modified in current commit) babel.config.js
Comments
Post a Comment