Notes on learning stage of developing my own React Native education app using Expo Framework - Part 4

Last updated on 19 Oct. 2025


Main tutorial used for learning: Simon Grimm: Build Your Own MASTERCLASS clone in React Native, https://www.youtube.com/watch?v=fO3D8lNs10c , 4 hrs. 42 mins, Mar. 2025.

Jump to Non-Critical Notes section (an easy way to get to just after end of main notes section and then scroll up to see latest main notes in this post).
=======================================

22:04, 11 October 2025

I am now reviewing the Waitlist part of the tutorial video.
In soon.tsx, SG imports Waitlist from '@clerk/clerk-react' Ref: https://youtu.be/fO3D8lNs10c?t=16009
SG gets "@clerk/clerk-react: Waitlist can only be used within the <ClerkProvider /> component...." error message when using web app's soon page. https://youtu.be/fO3D8lNs10c?t=16019

Even after he sets waitlistUrl prop in _layout.tsx for ClerkProvider component (Ref: https://youtu.be/fO3D8lNs10c?t=16047 ), he gets the same error for web (Ref: https://youtu.be/fO3D8lNs10c?t=16053 ).

But in _layout.tsx, at least in my app code which would have been copied from SG repo, I have:
import { ClerkProvider, ClerkLoaded, useAuth } from '@clerk/clerk-expo';

In my app currently, soon.web.tsx imports Waitlist from '@clerk/clerk-react'. I do not get the above error. Is it because it is a platform specific (web.tsx) page?

Also should SG have imported Waitlist from '@clerk/clerk-expo/web'? Ref: https://clerk.com/docs/expo/reference/components/authentication/waitlist

Don't know if I should also use import Waitlist from '@clerk/clerk-expo/web' . But as the code is working with import Waitlist from '@clerk/clerk-react', I don't want to make changes.

As I have mentioned, at least partially, in my earlier notes, SG adds 'use dom'; to top of soon.tsx and wraps the Waitlist in a ClerkProvider component also imported from @clerk/clerk-react (Ref: https://youtu.be/fO3D8lNs10c?t=16129 ) , after which the above error message goes away for web and the Waitlist screen is shown (and it also shows on mobile!).

But how does SG not get an error for two includes/imports of ClerkProvider (one from _layout.tsx and another from soon.tsx). Is it because they are imported from different packages ('@clerk/clerk-expo' and '@clerk/clerk-react')?

======================

Now starting next segment of video: Transfer Data to Strapi Cloud, https://www.youtube.com/watch?v=fO3D8lNs10c&t=16258s

https://strapi.io/pricing-cloud shows that free tier does not seem to have a trial period (which is what SG video shows, https://youtu.be/fO3D8lNs10c?t=16285 ). Hmm. Looks like that has changed for the better!

I created a login on Strapi cloud.
==================

As a first step before importing Ravi-lms-api from GitHub to Strapi cloud, I want to have all relevant data in the GH repo, at least made as similar to original SG lms-api repo as possible.
But I will exclude the 15 MB backup.tar.gz file. [Its assets/uploads folder contents size is same as public/uploads folder mentioned below, except for a 0 KB .gitkeep file in the latter, and so is the major part of the 15 MB file.]
The other big folder in the project on PC is public/uploads which is 14.2 MB.

The original .gitignore to which I have only added, already has:
public/uploads/*
!public/uploads/.gitkeep
----
That excludes all the big media files and 'unignores' or includes only the 0 KB .gitkeep file.
The database folder that I had included in .gitignore has only a 0 KB .gitkeep file in a subfolder migrations. Now I have removed the database folder line from .gitignore.
I dropped the banner.png exclusion (910 KB file).

So the only big exclude needed is backup.tar.gz. That is now the only added statement to .gitignore.
Commit: Made .gitignore very close to SG lms-api; Only additional statement now is exclusion of 15 MB backup.tar.gz file

Now I will try creating a new project in strapi cloud and importing code from https://github.com/ravisiyer/SG-EduApp-Ravi-LMS-API
STRAPI cloud Project name: sg-eduapp-ravi-lms-api
Connected above GH repo to it. 
Unchecked: Deploy the project on every commit pushed to this branch
Left the Region as default US (East) ... [Asia (Southeast) is an option but not Asia (South)]
I skipped adding env. variables at this stage.
Clicked Create project.
It finishes initialization and next step (configuration, IIRC) quite quickly. Last step of Deploying is taking longer.
It got "Deployed in 4 min and 4s".
App URL: https://majestic-benefit-7638ba90d8.strapiapp.com/admin
  Trying out above link in anonymous window brings up a login screen. So I am sharing above link on this post.

Clicking the 'Visit App' link took me to a cloud STRAPI credentials creation page. I created a user (saved info ...)
Now I see similar admin panel as in local instance with Content Manager showing the same Collection Types and Single Types. Only the data is empty in each of them. 
This is where I think SG video showing how to transfer data from local to cloud becomes important. I will follow that and see what happens.
I also have to set up the env vars. But let me transfer data first. Ref: https://youtu.be/fO3D8lNs10c?t=16398
npx strapi transfer --to url-of-hosted-instance
So for me, it becomes:
npx strapi transfer --to https://majestic-benefit-7638ba90d8.strapiapp.com/admin
It needs a transfer token.
Settings in App URL shows Transfer Token button on left.
I created token named: UploadFromLocal, duration: Unlimited, type: full access, Desc: blank
Saved and then copied token:
---snipped----------------------------
Ran:
npx strapi transfer --to https://majestic-benefit-7638ba90d8.strapiapp.com/admin
gave the token.
It took time (as per console output below 208118 ms which is around 3.5 minutes but it seemed a little longer to me - more like 5 to 10 minutes but I was not noting the time taken) to do the assets transfer (14.2 MB). I am on Jio SIM. Next time for such work, I should use Airtel
It got done eventually. Console output:
Ravi-lms-api> npx strapi transfer --to https://majestic-benefit-7638ba90d8.strapiapp.com/admin
? Please enter your transfer token for the remote Strapi destination [hidden]
? The transfer will delete existing data from the remote Strapi! Are you sure you want to proceed? Yes
Starting transfer...
✔ entities: 92 transfered (size: 48 KB) (elapsed: 3277 ms) (14.6 KB/s)
✔ assets: 16 transfered (size: 14.2 MB) (elapsed: 208118 ms) (70 KB/s)
✔ links: 211 transfered (size: 41 KB) (elapsed: 3122 ms) (13.1 KB/s)
✔ configuration: 31 transfered (size: 110.7 KB) (elapsed: 2180 ms) (50.8 KB/s)
┌─────────────────────────────────────────┬───────┬───────────────┐
│ Type                                    │ Count │ Size          │
├─────────────────────────────────────────┼───────┼───────────────┤
│ entities                                │    92 │        48 KB  │
├─────────────────────────────────────────┼───────┼───────────────┤
│ -- api::course.course                   │     4 │ (    10.1 KB) │
├─────────────────────────────────────────┼───────┼───────────────┤
│ -- api::home.home                       │     2 │ (     2.1 KB) │
├─────────────────────────────────────────┼───────┼───────────────┤
│ -- api::lesson.lesson                   │    30 │ (    12.1 KB) │
├─────────────────────────────────────────┼───────┼───────────────┤
│ -- api::progress.progress               │     4 │ (       1 KB) │
├─────────────────────────────────────────┼───────┼───────────────┤
│ -- api::user-course.user-course         │     6 │ (     1.8 KB) │
├─────────────────────────────────────────┼───────┼───────────────┤
│ -- plugin::i18n.locale                  │     1 │ (     253 B ) │
├─────────────────────────────────────────┼───────┼───────────────┤
│ -- plugin::upload.file                  │     4 │ (     8.8 KB) │
├─────────────────────────────────────────┼───────┼───────────────┤
│ -- plugin::users-permissions.permission │    37 │ (    10.2 KB) │
├─────────────────────────────────────────┼───────┼───────────────┤
│ -- plugin::users-permissions.role       │     2 │ (     651 B ) │
├─────────────────────────────────────────┼───────┼───────────────┤
│ -- plugin::users-permissions.user       │     2 │ (       1 KB) │
├─────────────────────────────────────────┼───────┼───────────────┤
│ assets                                  │    16 │      14.2 MB  │
├─────────────────────────────────────────┼───────┼───────────────┤
│ -- .mp4                                 │     1 │ (     4.5 MB) │
├─────────────────────────────────────────┼───────┼───────────────┤
│ -- .png                                 │    15 │ (     9.7 MB) │
├─────────────────────────────────────────┼───────┼───────────────┤
│ links                                   │   211 │        41 KB  │
├─────────────────────────────────────────┼───────┼───────────────┤
│ configuration                           │    31 │     110.7 KB  │
├─────────────────────────────────────────┼───────┼───────────────┤
│ Total                                   │   350 │      14.4 MB  │
└─────────────────────────────────────────┴───────┴───────────────┘
Transfer process has been completed successfully!
Ravi-lms-api> 
===========================

Checked the data on cloud. It is all OK including user, user-course and progress collection which show what it is in local STRAPI.
=============

Email from STRAPI cloud:
Thank you for being a Strapi Cloud customer. Please find below more information about your Strapi Cloud Free v1 USD Monthly subscription.

Subscription summary
Strapi Cloud Free $0.00

Next renewal on
01-Nov-2025 21:39
---------------------
Does that mean that there is a trial period which expires on 1 Nov. 2025?
-------

11:58, 12 October 2025

Strapi cloud dashboard - https://cloud.strapi.io/projects

Regarding environment variables settings (Accessed from Project -> Settings -> Environment -> Variables). It has already set the default variables to some values ...
---Key data from screen ---
Default environment variables
Each Strapi project comes with some mandatory variables to allow successful builds and deployments.
VARIABLE NAME

APP_KEYS  *****
API_TOKEN_SALT  ***** 
ADMIN_JWT_SECRET  *****
JWT_SECRET  *****
TRANSFER_TOKEN_SALT  *****

Custom environment variables
Control the environment your site gets deployed to. Please redeploy your project after editing variables.

---end key data from screen ---

So the only env. variables i need to set (as per .env in local project) are:
HOST='0.0.0.0'
PORT=1337
----------

Setting Description Type Default value
...
HOST Address the Strapi server listens on String 0.0.0.0
PORT Port used by the Strapi server Number 1337
-------------

So I don't think I need to set the above env. variables in strapi cloud.

-------
IFIRC, I have a valid free tier render a/c. So following article may be interesting:
Deploy Strapi on Render, https://render.com/docs/deploy-strapi (not read yet)
-------

Testing GET API via browser:
For localhost deployment, following url in browser works (gives courses data):
http://localhost:1337/api/courses
-----
But below url does not return data (browser spinner shows for long and then browser shows ERR_CONNECTION_TIMED_OUT)
https://majestic-benefit-7638ba90d8.strapiapp.com:1337/api/courses

But below url works (gives courses data)!
https://majestic-benefit-7638ba90d8.strapiapp.com/api/courses

CG explained it as follows:

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.

==========================

https://majestic-benefit-7638ba90d8.strapiapp.com/api/courses works even in incongito browser!
Hmm. I need to check about the security settings. 
...

On https://majestic-benefit-7638ba90d8.strapiapp.com/admin
 Settings > Users & Permissions Plugin > Roles -> Public -> Course
 shows that find and findOne permissions are checked and create, delete and update are unchecked.

 Settings > Users & Permissions Plugin > Roles -> Public -> Progress
 shows that find, findOne, create, delete and update are permissions are checked.

 Settings > Users & Permissions Plugin > Roles -> Public -> User-course
 shows that create,find and update permissions are checked and delete and findOne are unchecked.


 Settings > Users & Permissions Plugin > Roles -> App User -> Course
 shows that find and findOne permissions are checked and create, delete and update are unchecked.

 Settings > Users & Permissions Plugin > Roles -> App User -> Progress
 shows that find, findOne, create, delete and update are permissions are checked.

 Settings > Users & Permissions Plugin > Roles -> App User -> User-course
 shows that find, findOne, create, delete and update are permissions are unchecked.
 This is strange as Public for this collection has create,find and update permissions checked.
 Also how does this play out when the app tries to add an entry to the user-course?


On http://localhost:1337/admin
 Settings > Users & Permissions Plugin > Roles -> Public -> Course
 shows that find and findOne permissions are checked and create, delete and update are unchecked.
 That is same as on strapi cloud (see above).
 
 Settings > Users & Permissions Plugin > Roles -> Public -> Progress
 shows that find, findOne, create, delete and update are permissions are checked.
 That is same as on strapi cloud (see above).
 
 Settings > Users & Permissions Plugin > Roles -> Public -> User-course
 shows that create,find and update permissions are checked and delete and findOne are unchecked.
 That is same as on strapi cloud (see above).


 Settings > Users & Permissions Plugin > Roles -> App User -> Course
 shows that find and findOne permissions are checked and create, delete and update are unchecked.
 That is same as on strapi cloud (see above).

 Settings > Users & Permissions Plugin > Roles -> App User -> Progress
 shows that find, findOne, create, delete and update are permissions are checked.
 That is same as on strapi cloud (see above).

 Settings > Users & Permissions Plugin > Roles -> App User -> User-course
 shows that find, findOne, create, delete and update are permissions are unchecked.
 That is same as on strapi cloud (see above).
-----------

So local strapi admin settings for Users & Permissions Plugin matches that of strapi cloud. That is as expected.
But the following settings are strange even for a tutorial project where we would want to give read access to public for easy API testing (say, via browser):
 Settings > Users & Permissions Plugin > Roles -> Public -> Progress
 shows that find, findOne, create, delete and update are permissions are checked.
Why should Public user be allowed to create, delete and update?

 Settings > Users & Permissions Plugin > Roles -> Public -> User-course
 shows that create,find and update permissions are checked and delete and findOne are unchecked.
Why should Public user be allowed to create and update User-course?
 Settings > Users & Permissions Plugin > Roles -> App User -> User-course
 shows that find, findOne, create, delete and update are permissions are unchecked.
Why should App User be denied create, find and findOne?

=========
On https://majestic-benefit-7638ba90d8.strapiapp.com/admin

 Settings > Users & Permissions Plugin > Roles -> App User shows 2 users
 And Content Manager for User collection shows for both user entries a role of 'App User'
 I need to check the code that creates a user for this 'App User' setting.

 Settings > Users & Permissions Plugin > Roles -> App User does not list User collection under Permissions but lists all the other collections and has some more entries like Content types builder and Email.
 So looks like User collection in app is a special collection known to Strapi as App User collection.
 
 Settings > Administration Panel > Users lists "All the users who have access to the Strapi admin panel" which is only one now.
 So this panel's users is admin user and different from App User.
 
================
https://majestic-benefit-7638ba90d8.strapiapp.com/api/users from anonymous browser window shows the two registered user entries!
Hmm. How is API access to this collection controlled?

============================

Above security aspects are not critical to check now. 
I am now focusing on getting the web frontend on localhost to work with strapi cloud.

....
Me: In the React native tutorial project that uses above Strapi backend, the tutorial author mentioned having two .env files in the frontend - a .env and a .env.local with .env variable pointing to strapi cloud and .env.local pointing to localhost strapi.

Now does .env.local have any special meaning to Metro or webpack bundlers that come into play when i use npm run start to run the frontend?

[CG gave an incorrect answer that "In React Native, .env and .env.local are not automatically special". Actually, at least currently, Metro does prefer .env.local over .env. I don't know whether it also does an automatic merge or override of variables from these two files with .env.local being given priority.]

Now I needed multiple env files. CG suggested using script commands to ease that. That needed multiple iterations to get a working version which is given below.
In package.json:
  "scripts": {
    "start": "expo start",
    "start:local-be": "copy .env .env.back && copy .env.localback .env && expo start",
    "start:cloud-be": "copy .env .env.back && copy .env.cloud.be .env && expo start",
    "reset-project": "node ./scripts/reset-project.js",
    ...
  },
----
Note that the above works on Windows 11 but most probably will not work on Linux/macOS.

I also keep a separate .env.cloud.fs which will have values to be used when front-end and back-end are on cloud or not local. In this case, .env.cloud.fs will simply be a convenience file to upload env variable values into hosting service like EAS.

Added these multiple .env files as excluded using following in .gitignore
.env.*
-----

With the above setup:
  • 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
=============

I need to drop assets from .gitignore before hosting frontend on EAS or similar service.
...

app\api\add-user-course+api.ts uses STRAPI_API_ADMIN_TOKEN to add user course via API. That's why the role permissions for user-course don't matter, I think.

https://docs.strapi.io/cms/data-management/transfer states "Admin users and API tokens are not transferred."

So looks like I will need to regenerate the STRAPI_API_ADMIN_TOKEN on cloud and use that regenerated value in env.cloud.be and env.cloud.fs.
Did that.

To test it (in PS):

curl https://majestic-benefit-7638ba90d8.strapiapp.com/api/courses
returned 200 Status code and JSON data in Content.

curl https://majestic-benefit-7638ba90d8.strapiapp.com/api/user-courses
returned 200 Status code and JSON data in Content.

I think the above is happening as on https://majestic-benefit-7638ba90d8.strapiapp.com/admin
 Settings > Users & Permissions Plugin > Roles -> Public -> User-course
 shows that create,find and update permissions are checked and delete and findOne are unchecked.

To protect User-course from anonymous create, find and update, I need to uncheck them in above. ... Did that and saved in cloud.
Now ...
lms> curl https://majestic-benefit-7638ba90d8.strapiapp.com/api/user-courses
curl : {"data":null,"error":{"status":403,"name":"ForbiddenError","message":"Forbidden","details":{}}}
At line:1 char:1
+ curl https://majestic-benefit-7638ba90d8.strapiapp.com/api/user-cours ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : InvalidOperation: (System.Net.HttpWebRequest:HttpWebRequest) [Invoke-WebRequest], WebException
    + FullyQualifiedErrorId : WebCmdletWebResponseException,Microsoft.PowerShell.Commands.InvokeWebRequestCommand
lms> 
-----
So the data protection from public works.

curl -H 'Authorization: Bearer --snipped--' https://majestic-benefit-7638ba90d8.strapiapp.com/api/user-courses

lms> curl -H 'Authorization: Bearer bd-snipped-' https://majestic-benefit-7638ba90d8.strapiapp.com/api/user-courses
Invoke-WebRequest : Cannot bind parameter 'Headers'. Cannot convert the "Authorization: Bearer bd-snipped-" value of type "System.String" to type 
"System.Collections.IDictionary".
At line:1 char:9
+ curl -H 'Authorization: Bearer bd--snipped-- ...
+         ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : InvalidArgument: (:) [Invoke-WebRequest], ParameterBindingException
    + FullyQualifiedErrorId : CannotConvertArgumentNoMessage,Microsoft.PowerShell.Commands.InvokeWebRequestCommand
 
lms> 

On asking CG for command prompt version in PS, it suggested:
cmd /c curl -H "Authorization: Bearer bd--snipped--" https://majestic-benefit-7638ba90d8.strapiapp.com/api/user-courses
...

lms> cmd /c curl -H "Authorization: Bearer bd--snipped--" https://majestic-benefit-7638ba90d8.strapiapp.com/api/user-courses
>>
{"data":[{"id":5,"documentId":"lj7bj7293s80l3w3iq9wtpww","clerkId":"user_2xBpZwuCMK7AUEKRFT4Q1hcm9uk","finished_percentage":10,"next_lesson_index":"2","createdAt":"2025-10-09T18:11:42.154Z","updatedAt":"2025-10-10T08:18:59.002Z","publishedAt":"2025-10-10T08:18:59.011Z"},{"id":2,"documentId":"mo06lybnrsehtdrd09a5k4r9","clerkId":"user_2xBpZwuCMK7AUEKRFT4Q1hcm9uk","finished_percentage":20,"next_lesson_index":"2","createdAt":"2025-10-01T15:05:19.834Z","updatedAt":"2025-10-08T18:50:10.752Z","publishedAt":"2025-10-08T18:50:10.759Z"},{"id":6,"documentId":"vjcisu67y1r7x2kmbvm2rqlh","clerkId":"user_2xM5iCisxx9uJQ3XjDJM83T0oLz","finished_percentage":30,"next_lesson_index":"4","createdAt":"2025-10-09T18:15:35.351Z","updatedAt":"2025-10-11T11:58:21.748Z","publishedAt":"2025-10-11T11:58:21.757Z"}],"meta":{"pagination":{"page":1,"pageSize":25,"pageCount":1,"total":3}}}
lms> 

cmd /c curl https://majestic-benefit-7638ba90d8.strapiapp.com/api/courses
{"data":[{"id":4,"documentId":"caidhct21sy7lhabvqhauho9","title":"🧘‍♀️ Beginner’s Yoga Journey – Flow Into Balance 🌿","description n":[{"type":"paragraph","children":[{"text":"✨ Welcome to Your Yoga Practice! ✨","type":"text"}]},{"type":"paragraph","children":[{"text":"Step onto the mat and begin a journey of self-discovery, movement, and breath. This beginner-friendly yoga course is designed to help you build strength, improve flexibility, and cultivate mindfulness—one breath at a time.","type":"text"}]},{"type":"paragraph","children":[{"text":"","type":"text"}]},{"type":"paragraph","children":[{"text":"💛 What You’ll Learn:","type":"text"}]},
...
------------------
======================

Me: Now about API key security issue. The code that uses this env variable is in: app\api\add-user-course+api.ts
So it is an API in React native and Expo setup.
No other source file uses this env variable.
Is this API key protected from client or exposed to client

[CG initially gave an inaccurate response perhaps based on old data. It also suggested how to test on web client using DevTools Console whether the API key env variable is exposed...]

Me: [Sharing DevTools Console data after trying out above CG suggestion:] 
Warning: Don’t paste code into the DevTools Console that you don’t understand or haven’t reviewed yourself. This could allow attackers to steal your identity or take control of your computer. Please type ‘allow pasting’ below and press Enter to allow pasting.
allow pasting
console.log('Environment:', process.env.STRAPI_API_ADMIN_TOKEN);

VM328:1 Environment: undefined
undefined
----

CG: ...

So your test confirms:

  • On the web, STRAPI_API_ADMIN_TOKEN is not exposed.

  • On mobile bundles, if you import it in client code, it could be exposed.

...

[In response to my query, ] CG said, "Yes — if you want to directly check whether the admin token is bundled in the mobile app, you will need to temporarily add a small console.log in your source code."
...

Me: I think this is a new feature. https://docs.expo.dev/router/reference/api-routes/ says:

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-config and requires it to be used in the metro.config.js.
---------

CG agreed and said: With Expo Router API routes, files like 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.
...

Me: For the web app, it will be like Next.js server side stuff, I guess - so I get that.
But for mobile app, how does the server side stuff get invoked? Where is that code running? Does it latch on to the web app deployed somewhere and whose url is available in some config file?

...[Omitting CG response as it was not relevant.]

Me: My app does not have a EXPO_PUBLIC_BASE_URL anywhere.
But app.json has:
    "plugins": [
      [
        "expo-router",
        {
          "origin": "https://fake-devonly-lms.expo.app"
        }
      ],
      [
        "expo-splash-screen",
        {
          "image": "./assets/images/splash-icon.png",
          "imageWidth": 200,
          "resizeMode": "contain",
          "backgroundColor": "#ffffff"
        }
      ],
      [
        "expo-video",
        {
          "supportsBackgroundPlayback": true,
          "supportsPictureInPicture": true
        }
      ]
    ],
------
I think in production, the expo-router origin has to be set up to point to deployed web app. That must be the way the mobile app knows where to go to invoke the api hosted on server.

CG: Exactly — you’ve got it. That origin in your app.json tells the Expo Router where the server-side API routes live for your mobile app to call.
...

Me: In the tutorial, STRAPI_API_ADMIN_TOKEN is defined in .env file.
I confirmed through search across project code that other than .env file(s) (which are not exposed to client or on repo), the only file using it is the api source code file. 

So I guess that even though STRAPI_API_ADMIN_TOKEN is defined in .env file, it will not be accessible to mobile or web client.

But would it not be better to include it in some constants.js file in the api folder (if used in multiple apis) or directly in the api file (if used only in one api file)? This way even if some client code mistakenly refers to process.env.STRAPI_API_ADMIN_TOKEN , it won't work or be visible to it?

Or is it that since we don't have EXPO_PUBLIC_ prefix to STRAPI_API_ADMIN_TOKEN we can be sure that process.env.STRAPI_API_ADMIN_TOKEN will not be accessible on client code?

...

Me: U wrote: Because your token is non-prefixed (EXPO_PUBLIC_) and only used inside +api.ts route handlers, it is not accessible to mobile or web clients. Using a constants file in api/ can help prevent accidental exposure, but .env usage is already safe as long as the code separation is maintained.
-----
The key point is that it is not prefixed with EXPO_PUBLIC_ and so even if it is in .env file (or .env.local or .env.cloud.be which we copy as .env when needed), we are assured that even if by mistake some client side code refers to it, it will be undefined. Did I get that right?

CG: Perfectly right — ✅ you’ve captured the core guarantee of Expo’s environment variable system.
...
In Expo, only environment variables prefixed with 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.
...
-----------------
Discussion on .env variants ...
.env.local does get into play before .env in Expo
So renamed it to .env.localback
--------

Stopped lms-api local back-end.
Ran: [npm run] start:cloud-be
lms web front-end did connect to cloud backend but some issues:
1) Images were not shown (as for Dev we prefix image url with EXPO_PUBLIC_STRAPI_API_URL which seems to be an issue for strapi cloud)...
2) User-courses was not accessible. Fixed that by giving Cloud Role public access to User-course find and findOne. [I wanted to stay as close to tutorial as feasible. So did not change App User role for User-course which has all operations unchecked (disallowed).]

Commit: local frontend works partially with cloud strapi backend; Images are not shown

Discussed code to detect local or cloud strapi backend and modify image/video url correctly. In providers\StrapiProvider.tsx:

  const baseUrl = process.env.EXPO_PUBLIC_STRAPI_API_URL as string;
  let backendMode: 'local' | 'cloud' | 'unknown';

  if (baseUrl.startsWith('https://')) {
    backendMode = 'cloud';
  } else if (baseUrl.startsWith('http://')) {
    backendMode = 'local';
  } else {
    backendMode = 'unknown';
  }

  console.log(`Strapi backendMode: ${backendMode}`);
  console.log(`Strapi API URL: ${baseUrl}`);
...
      result.data = result.data.map((item: any) => {
        const image = (__DEV__) && backendMode === 'local'
        ? `${baseUrl}${item.image.url}`  // In Development
        : `${item.image.url}`
        return {
          ...item,
          image,
        }
      });
...
---------
Above approach worked (showed images and video on app frontend).

But progress to next lesson gave forbidden error for User-course.
So added update permission for Public to User-course in cloud Strapi.
That fixed it.

Switching between local and cloud strapi using:
npm run start:cloud-be
and
npm run start:local-be
worked. The images and video were shown correctly in both cases.

Commit: Fixed image issue with code to detect local or cloud strapi backend and modify image/video url correctly

Now testing local and then cloud backend with app on phone.

One issue with cloud backend on mobile (and I think on web too but I need to confirm that), is that moving to next lesson automatically from playing current lesson to end, sometimes starts the video and then restarts it. The restart is visible as the video plays a little. When I switched back to local backend, I saw that the restart happens on local backend too, except that the restart is quick and so harder to spot.

Modified lesson/index.tsx as follows:
  if (lesson?.video) {
    // Does this cause the video to restart at times?
    console.log("Replacing video in player for lessonIndex", lessonIndex);
    player.replace(lesson.video);
  }
-----

After above change, I am not facing the restart issue using local strapi on mobile. But that seems to be a coincidence as above addition is just a console.log statement. Perhaps it needs some other condition to manifest. But now I have the console.log statements to check when the issue arises.
I tested for long on local strapi with mobile app, going through entire lesson set of Beginner's Yoga ... course twice. No restart and no repeat of console message for a lesson index.

Then I switched to cloud strapi. Very quickly I got the restart and the above console message for the same lesson index was repeated:
 (NOBRIDGE) LOG  In playToEnd event handler. Ignoring spurious playToEnd event during mount
 (NOBRIDGE) LOG  Replacing video in player for lessonIndex 8
 (NOBRIDGE) LOG  In useEffect for updating course progress. externalNavigationRef.current = true
 (NOBRIDGE) LOG  Entered updateUserCourseProgress with: {"courseId": "caidhct21sy7lhabvqhauho9", "mapToUserCourse": true, "nextLessonIndex": 8, "progress": 70}
DOM Bundled 172ms node_modules\expo\dom\entry.js (371 modules)
 LOG  [webview] Logs will also appear in the Safari/Chrome debug console
 LOG  Running application "main" with appParams:
 {"hydrate": undefined, "rootTag": "#root"}
Development-level warnings: ON.
Performance optimizations: OFF.
 (NOBRIDGE) LOG  Mapped courseId to userCourseId: vjcisu67y1r7x2kmbvm2rqlh
 (NOBRIDGE) LOG  isMounted is true in playToEnd event handler. Will now invoke onHandleCompleteLesson.
 (NOBRIDGE) LOG  Entered onHandleCompleteLesson
 (NOBRIDGE) LOG  Invoking markLessonAsCompleted with: {"courseId": "caidhct21sy7lhabvqhauho9", "lessonId": "i7kb0qpdpzg104gc42ziqijj", "nextLessonIndex": 9, "progress": 80}
 (NOBRIDGE) LOG  Replacing video in player for lessonIndex 9
 (NOBRIDGE) LOG  In useEffect for updating course progress. externalNavigationRef.current = false
DOM Bundled 16ms node_modules\expo\dom\entry.js (1 module)
 LOG  [webview] Logs will also appear in the Safari/Chrome debug console
 LOG  Running application "main" with appParams:
 {"hydrate": undefined, "rootTag": "#root"}
Development-level warnings: ON.
Performance optimizations: OFF.
 (NOBRIDGE) LOG  Entered updateUserCourseProgress with: {"courseId": "vjcisu67y1r7x2kmbvm2rqlh", "mapToUserCourse": false, "nextLessonIndex": 9, "progress": 80}
 (NOBRIDGE) LOG  Replacing video in player for lessonIndex 9
 (NOBRIDGE) LOG  In useEffect for updating course progress. externalNavigationRef.current = true
 (NOBRIDGE) LOG  Entered updateUserCourseProgress with: {"courseId": "caidhct21sy7lhabvqhauho9", "mapToUserCourse": true, "nextLessonIndex": 9, "progress": 80}
 (NOBRIDGE) LOG  Mapped courseId to userCourseId: vjcisu67y1r7x2kmbvm2rqlh

commit: Added debugging code for video restart and overview.tsx RichtTexTContent initial props not defined possibility

CG provided a useRef flag fix. ... some iterations ... That seems to work. 
Current code in app\(app)\(authenticated)\course\[slug]\[lesson]\index.tsx  (the commented code covers an issue discussed later in this post):
  const lastLessonIndexRef = useRef<string | null>(null);
...
  if (lesson?.video) {
    if (lastLessonIndexRef.current !== lessonIndex) {
      console.log("Replacing video in player for lessonIndex", lessonIndex);
      // if (Platform.OS === 'web') {
      // // Only mutes the audio; Does not autoplay on web
      //   player.muted = true; 
      // }
      player.replace(lesson.video);
      // player.play(); // Does not autoplay on web; Not needed for Android
        // see [expo-video] Autoplay doesn't work for video on Web #36350, 
        // https://github.com/expo/expo/issues/36350
      lastLessonIndexRef.current = lessonIndex;
    } else {
      // Optional debug log:
      console.log("Skipping replace, same lessonIndex", lessonIndex);
    }
  }
----
Commit: Fix for video restart
================

The below message is from some time in the course of above testing. It is noted separately as it is a different error.
Also I got an "Initial props are not defined. This is a bug in the DOM Component runtime" error on mobile.
Metro console data:
 ERROR  The above error occurred in the <DOMComponentRoot> component:

    at DOMComponentRoot (http://10.50.171.151:8081/node_modules%5Cexpo%5Cdom%5Centry.js.bundle?platform=web&dev=true&hot=false&lazy=true&transform.engine=hermes&transform.baseUrl=%2F&transform.routerRoot=app&transform.dom=.%2F..%2F..%2F..%2Fcomponents%2FRichtTextContent.tsx&unstable_transformProfile=hermes-stable:36434:58)
    at LogBoxStateSubscription (http://10.50.171.151:8081/node_modules%5Cexpo%5Cdom%5Centry.js.bundle?platform=web&dev=true&hot=false&lazy=true&transform.engine=hermes&transform.baseUrl=%2F&transform.routerRoot=app&transform.dom=.%2F..%2F..%2F..%2Fcomponents%2FRichtTextContent.tsx&unstable_transformProfile=hermes-stable:35898:9)
    at ErrorOverlay
    at withDevTools(ErrorOverlay)
    at AppContainer (http://10.50.171.151:8081/node_modules%5Cexpo%5Cdom%5Centry.js.bundle?platform=web&dev=true&hot=false&lazy=true&transform.engine=hermes&transform.baseUrl=%2F&transform.routerRoot=app&transform.dom=.%2F..%2F..%2F..%2Fcomponents%2FRichtTextContent.tsx&unstable_transformProfile=hermes-stable:56273:5)

React will try to recreate this component tree from scratch using the error boundary you provided, LogBoxStateSubscription.
 ERROR  Warning: Can't perform a React state update on a component that hasn't mounted yet. This indicates that you have a side-effect in your render function that asynchronously later calls tries to update the component. Move this work to useEffect instead.      
    at LogBoxStateSubscription (http://10.50.171.151:8081/node_modules%5Cexpo%5Cdom%5Centry.js.bundle?platform=web&dev=true&hot=false&lazy=true&transform.engine=hermes&transform.baseUrl=%2F&transform.routerRoot=app&transform.dom=.%2F..%2F..%2F..%2Fcomponents%2FRichtTextContent.tsx&unstable_transformProfile=hermes-stable:35898:9)
    at ErrorOverlay
    at withDevTools(ErrorOverlay)
    at AppContainer (http://10.50.171.151:8081/node_modules%5Cexpo%5Cdom%5Centry.js.bundle?platform=web&dev=true&hot=false&lazy=true&transform.engine=hermes&transform.baseUrl=%2F&transform.routerRoot=app&transform.dom=.%2F..%2F..%2F..%2Fcomponents%2FRichtTextContent.tsx&unstable_transformProfile=hermes-stable:56273:5)
 (NOBRIDGE) LOG  In useEffect for updating course progress. externalNavigationRef.current = true
 (NOBRIDGE) LOG  Entered updateUserCourseProgress with: {"courseId": "caidhct21sy7lhabvqhauho9", "mapToUserCourse": true, "nextLessonIndex": 4, "progress": 30}
 (NOBRIDGE) LOG  Mapped courseId to userCourseId: vjcisu67y1r7x2kmbvm2rqlh
=======================
Dismissing the error on mobile app leads to app working OK.
So this is a flaky kind of error and may not be related to the restart issue as that happens every time.

This error occurred only once during fair amount of testing. The code in lesson/index.tsx prevents this condition from happening by putting out a text message instead of using RichtTextContent component. Now added similar code in lesson/overview.tsx. 
So when user is in lesson page, this error should not show on app but the text message of "No notes available for this lesson." or "No description for this course." may be shown. If that happens, the cause should be investigated. However, the user will not have an app crash message.

Otherwise the app seems to work correctly on mobile with cloud backend as well as local backend.
==================

On Android device, player.replace(lesson.video); 
seems to cause video to also play.
On Web, it does not automatically play. On pressing play button it plays.

Tried
player.replace(lesson.video);
player.play();

Did not work.

CG: ... this is very likely a browser autoplay-policy issue, not a problem with your React Native or Expo code.
...

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:

player.replace(lesson.video); player.play();

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):

document.querySelector("video").play();

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.

...

Me: [expo-video] Autoplay doesn't work for video on Web #36350, https://github.com/expo/expo/issues/36350

CG: Excellent — yes, that GitHub issue #36350 is the exact upstream cause of what you’re seeing. 🎯

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:

    player.play();

    after player.replace() or player.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 after replace() 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)

...

[CG suggested muting the player before player.replace() statement. That only muted the audio and did not play the video. I have left the code related to this in the file in commented form with some explanation comments.]

Based on above, I decided not to invest further time on this issue. It was not worth the time, as of now, to try out some other workarounds suggested in above Expo GitHub issue link.
========

I think a little earlier, I modified code to show confetti on last lesson of course playing to end on mobile

Commit: Confetti shown on last lesson playing to end on mobile; Unsuccessful straightforward attempts to get web app to autoplay video
=========================

18:15, 13 October 2025

Starting next segment: Deploy your Website through EAS Hosting, https://www.youtube.com/watch?v=fO3D8lNs10c&t=16658s
Added +html.tsx and +not-found.tsx from repo (build-failed-lms-react-native folder on PC) to project app folder.

SG says @ https://youtu.be/fO3D8lNs10c?t=16708 that in .env file setting:
EXPO_UNSTABLE_DEPLOY_SERVER=1
allows EAS (hosting) to automatically update app.json expo-router origin (which I presume is to the url the app gets hosted on).
Related entry in app.json:
        "expo-router",
        {
          "origin": "https://fake-devonly-lms.expo.app"
        }
----

I made the above .env setting in .env.cloud.fs
------
[I think I will have to copy .env.cloud.fs to .env before following command.]
npx expo export --platform web

Could I use the following package.json script for the above:
    "export-web": "copy .env .env.back && copy .env.cloud.fs .env && expo export --platform web",

CG was OK with above. Made the change to package.json.
Ran: npm run export-web

That generated a dist folder to which web app is exported. Console output:
lms> npm run export-web

> lms@1.0.0 export-web
> copy .env .env.back && copy .env.cloud.fs .env && expo export --platform web

        1 file(s) copied.
        1 file(s) copied.
env: load .env
env: export EXPO_PUBLIC_STRAPI_API_URL EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY EXPO_PUBLIC_REVENUECAT_WEB_KEY STRAPI_API_ADMIN_TOKEN EXPO_UNSTABLE_DEPLOY_SERVER EXPO_PUBLIC_CLERK_WAITLIST_ON
Starting Metro Bundler
Static rendering is enabled. Learn more: https://docs.expo.dev/router/reference/static-rendering/
λ Bundled 76597ms node_modules\expo-router\node\render.js (1563 modules)
Web Bundled 77370ms node_modules\expo-router\entry.js (1541 modules)
λ  LOG  Main _layout: In RootLayout
λ  LOG  Main _layout: In RootLayout
λ  LOG  Main _layout: In RootLayout
λ  LOG  Main _layout: In RootLayout
λ  LOG  Main _layout: In RootLayout
λ  LOG  Main _layout: In RootLayout
λ  LOG  Main _layout: In RootLayout
λ  LOG  Main _layout: In RootLayout
λ  LOG  Main _layout: In RootLayout
λ  LOG  Main _layout: In RootLayout
λ  LOG  Main _layout: In RootLayout
λ  LOG  Main _layout: In RootLayout
λ  LOG  Main _layout: In RootLayout
λ Bundled 517ms app\api\add-user-course+api.ts (1 module)

› web bundles (2):
_expo/static/css/web-a6992c045c09d22a5a3c0e82d9bf27fd.css (13.4 kB)
_expo/static/js/web/entry-54d2fe2f10d34e94748be459de22f6c5.js (3.69 MB)

› Static routes (13):
/soon (16.8 kB)
/wait (16.8 kB)
/ (index) (16.8 kB)
/login (16.8 kB)
/_sitemap (16.8 kB)
/+not-found (16.8 kB)
/(app)/(authenticated)/courses (16.8 kB)
/(app)/(authenticated)/(tabs) (16.8 kB)
/(app)/(authenticated)/course/[slug] (16.8 kB)
/(app)/(authenticated)/(tabs)/profile (16.8 kB)
/(app)/(authenticated)/(tabs)/my-content (16.8 kB)
/(app)/(authenticated)/course/[slug]/[lesson] (16.8 kB)
/(app)/(authenticated)/course/[slug]/[lesson]/overview (16.8 kB)

› API routes (1):
/api/add-user-course (3.92 kB) (source map (5.86 kB))

Exported: dist
lms> 
===================
As a double-check, I confirmed that .env is same as .env.cloud.fs.
dist folder was created with size of 9.25 MB containing 59 files and 39 folders.
==========================

Created EAS project: SG-EduApp-LMS

On PC console:
eas whoami shows that I am logged in

EAS instructions to link existing local project for an existing codebase: npm install --global eas-cli && eas init --id --snipped--

I think I will run npm install --global eas-cli to update it to latest version. (current version is eas-cli/16.1.0 win32-x64 node-v22.16.0. It says eas-cli@16.12.0 is now available)

Ran: npm install --global eas-cli
That succeeded.
lms> eas --version
eas-cli/16.21.0 win32-x64 node-v22.16.0
lms> 

Ran:
lms> eas init --id --snipped--
√ Project successfully linked (ID: --snipped--) (modified app.json)
√ Project slug (sg-eduapp-lms) does not match the value configured in the "slug" field (lms).. Do you wish to overwrite it? ... yes
lms> 

lms> eas env:push --path .env
√ Select environment: » development
Uploaded env file to development.
lms> 

[I could have used .env.cloud.fs above, I guess. Anyway, at this stage .env is same as .env.cloud.fs.]
-----------
About eas deploy: takes export that was done earlier and deploys to eas hosting
After deployment, SG runs the deployed app, and takes the part of the url before -- , which in his case is supersimon-lms, and changes app.json expo-router origin to:
https://supersimon-lms.expo.app [It opens up the login page! So may be fully active. Would be interesting to try it out later and compare with my app.]
He says that that is how it will be in production environment (what he deploys to eas in the video seems to be development version).
After above change, SG says building iOS app (will apply to Android too, I guess), will use above origin for EXPO API calls. That, IFIRC, matches what CG had said in discussion covered earlier in this post.
Ref: https://youtu.be/fO3D8lNs10c?t=16825
-----------

I ran: ...
lms> eas deploy
EAS Hosting is still in preview and subject to changes.
> Project export: server - exported 3 hours ago
√ Choose a preview URL for your project: ... sg-eduapp-lms.expo.app
✔ Created deployment
|■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■| 100% Uploading 43 assets

🎉 Your deployment is ready

Dashboard       https://expo.dev/projects/--snipped--/hosting/deployments

🚀 When you are ready to deploy to production:
  $ eas deploy --prod
lms> 
-----------

It seems to be working OK!

That was super smooth! No trip-ups at all!

Commit: Deployed frontend to EAS; EAS app is working with STRAPI cloud backend
===============================

14 October 2025

On web, Home page image vertical clipping needs to be fixed. IFIRC, initially SG app had that issue. But later SG app does not seem to show that issue.

Apple login button needs to be hidden.

After above, production deploy can be considered.

Me: The web app got deployed on eas and seems to work well.
Changing STRAPI URL and API key on eas web app is simple as we need to simply modify the env variables and restart the deployed app.

But when I get to Android app production APK, I will face issues. If STRAPI URL and API key have to be changed in production APK, I will have to rebuild the APK, isn't it?

CG:Exactly right ✅ — your understanding is spot-on again.

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:

{ "apiBaseUrl": "https://majestic-benefit-7638ba90d8.strapiapp.com", "publicAssetsBaseUrl": "https://cdn.example.com" }

Then, in your app startup code:

const config = await fetch('https://yourdomain.com/config.json').then(r => r.json()); const baseUrl = config.apiBaseUrl;

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:

  1. Update .env.cloud.fs

  2. Run eas update

  3. Users automatically get the updated API URL and key at next app launch.


...

Me: EAS OTA is fascinating! If only JS stuff has changed, it will update installed apps on mobiles on next launch of app on those mobiles! Wow! Did I get that right?

CG: 💯 You got it exactly right! 🎯

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:

eas update --branch production

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 prebuild or modifying Android/iOS native code)

  • App icons, splash screens, or any assets bundled at native build time

  • Permissions declared in AndroidManifest.xml or Info.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

# 1. Make JS-only code change # 2. Commit & test locally # 3. Push OTA update eas update --branch production --message "Fixed course video autoplay bug"

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-push native module.

  • Handles differential updates, rollbacks, and multiple environments (staging, prod).

  • Needs manual setup and an App Center account.

Example:

appcenter codepush release-react -a user/app -d Production

✅ 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 AppRegistry to 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

FeatureExpo EAS UpdateMicrosoft CodePushCustom 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
CostIncluded in Expo planFree tier / paidInfra cost

💡 TL;DR

OTA updates are a React Native concept, not just an Expo feature.
EAS Update makes it effortless for Expo users, while CodePush and custom systems serve bare React Native apps.
All of them let you deliver new JS and assets instantly — skipping the Play Store/App Store bottleneck.

...

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:

import { useUpdates } from 'expo-updates'; const { isUpdateAvailable, isUpdatePending, downloadUpdateAsync,
reloadAsync } = useUpdates();

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:

codePush.sync({ updateDialog: true, // 👈 show popup to user installMode: codePush.InstallMode.IMMEDIATE, });

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

ScenarioEAS UpdateCodePush
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

Users normally don’t see any prompt — OTA updates “just happen” quietly.
But developers can add UI for user control if they wish (especially easy with CodePush, possible with EAS Update via useUpdates()).
...

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:

  1. It checks a remote server for a new JavaScript (or Dart) bundle.

  2. If a new one exists, it downloads it silently while you use the app.

  3. 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.manifest or updates.manifest → Expo OTA

  • assets/codepush.json or codepush folder → 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.json for

    • "expo-updates" → Expo OTA

    • "react-native-code-push" → Microsoft CodePush

  • In Expo, check app.json"updates" section:

    "updates": { "enabled": true, "fallbackToCacheTimeout": 0 }

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:

Updates: checking for updates... Updates: no updates available

or

Updates: new update found, downloading...

That’s Expo’s OTA update mechanism reporting itself.


🛡️ Summary Table

MethodDetects OTA?Typical Sign
App behaviorUI changes without Play Store update
Splash text“Checking for updates…”
Decompile APKexpo-updates or codepush files
Network logsRequests to Expo / CodePush servers
Source code checkOTA 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.


[---Snipped example code as CG provided Next.js related code. Later part gives RN & Expo code.---]

...
Me: can u provide me a good article on the web on this

CG: Good idea — having a solid article reference helps. Here are a few that cover runtime configuration, OTA updates, Expo environment handling, and related patterns. Also, I’ll highlight which ones best match what you’re doing (config API, dynamic URL switching, etc.).

📚 Useful articles & docs

  1. 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)

  2. OTA Updates With Expo (Pagepro)
    A practical guide to implementing OTA updates with Expo, including runtime behavior. (pagepro.co)

  3. 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)

  4. Configure with app config – Expo Documentation
    This Expo doc explains how to use app.config.js / app.json to provide dynamic configuration merged with environment variables. Useful background for using dynamic configs. (Expo Documentation)

  5. Expo App Config Setup for Multiple Environments (Grant Isom blog)
    How to convert static app.json to dynamic app.config.js, and enable environment-based config switches. (grantisom.com)

  6. 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)

  7. 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

export async function GET() { try { const strapiUrl = process.env.EXPO_PUBLIC_STRAPI_API_URL; if (!strapiUrl) { return new Response( JSON.stringify({ error: 'Missing EXPO_PUBLIC_STRAPI_API_URL' }), { status: 500, headers: { 'Content-Type': 'application/json' } } ); } return new Response( JSON.stringify({ strapiUrl }), { status: 200, headers: { 'Content-Type': 'application/json' } } ); } catch (err: any) { console.error('Error in config API:', err); return new Response( JSON.stringify({ error: 'Internal server error' }), { status: 500, headers: { 'Content-Type': 'application/json' } } ); } }

📱 Client-side code example

You can call it from your React component, context, or API wrapper:

async function getStrapiUrl() { try { const response = await fetch(`${process.env.EXPO_PUBLIC_APP_URL}/api/config`); if (!response.ok) { console.error('Config API failed:', response.status, response.statusText); return null; // or throw new Error('Config fetch failed') } const data = await response.json(); if (data.error) { console.error('Config API returned error:', data.error); return null; } return data.strapiUrl; } catch (error) { console.error('Network or fetch error:', error); return null; } }

✅ Why this pattern is good

  • Handles missing env vars gracefully.

  • Logs both server and client errors for debugging.

  • Prevents crashes from uncaught rejections.

  • Returns null on 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

BenefitDescription
🌀 No rebuilds neededChanging Strapi backend = just update .env on server and restart.
🔒 Secure separationExpo backend knows the real Strapi URL and token; client doesn’t.
⚙️ Consistent across web & AndroidBoth apps fetch the same dynamic config.
🚀 Future flexibilityYou can later extend this same endpoint to return feature flags, app version info, maintenance mode flags, etc.

⚠️ 4. Small Design Tips

  1. 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.

  2. Add a version or timestamp in the config response to know when it changes.

  3. Use HTTPS only for this endpoint, especially since it provides backend routing info.


🧠 5. Summary

StepDescription
✅ Create /api/config endpointReturns Strapi URL (and maybe more)
✅ Client fetches it on startupStores result in memory or AsyncStorage
✅ You can change backend anytimeNo rebuild or OTA update needed
✅ Web and mobile share same logicUnified 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?

...

Me: The project (based on tutorial code), uses EXPO_PUBLIC_STRAPI_API_URL in only two files - 
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?

[CG agreed. I shared the code of the two files but CG was not able to integrate the above discussion of using API route to get STRAPI URL properly in its solution. ... some iterations ...]

CG provided code:
export function StrapiProvider({ children }: { children: ReactNode }) {
  const [baseUrl, setBaseUrl] = useState<string | null>(null);
  const apiURLLoaded = useRef(false);

  useEffect(() => {
    (async () => {
      try {
        const res = await fetch('/api/config');
        if (!res.ok) throw new Error(`Failed to fetch config: ${res.status}`);
        const data = await res.json();
        setBaseUrl(data.strapiUrl);
        apiURLLoaded.current = true;
        console.log('✅ Strapi API URL loaded:', data.strapiUrl);
      } catch (err) {
        console.error('❌ Failed to load Strapi API URL:', err);
      }
    })();
  }, []);

  const ensureReady = () => {
    if (!apiURLLoaded.current || !baseUrl) {
      throw new Error('Strapi API not ready yet');
    }
  };

  const isApiUrlLoaded = () => apiURLLoaded.current;

  const getCourses = async (): Promise<Course[]> => {
    ensureReady();
    const res = await fetch(`${baseUrl}/api/courses?populate=image`);
    if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
    const result = await res.json();
    return result.data;
  };

  // ... other functions call ensureReady() similarly

  const value = {
    getCourses,
    // ...other Strapi functions
    isApiUrlLoaded, // <-- exported for top-level usage
  };

  return <StrapiContext.Provider value={value}>{children}</StrapiContext.Provider>;
}


Usage in App Component

At the top level of your app:

const { isApiUrlLoaded } = useStrapi(); if (!isApiUrlLoaded()) { return <LoadingSpinner />; // Show until STRAPI URL is fetched } // Render rest of app return <AppContent />;

✅ 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 the useEffect. 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. Calling useStrapi() 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.

...

Me: Now about the APIs on Expo server. our (app/api/config+api.ts) API (uses) process.env.EXPO_PUBLIC_STRAPI_API_URL :
...
    const strapiUrl = process.env.EXPO_PUBLIC_STRAPI_API_URL;
...

So does our other API app\api\add-user-course+api.ts :
...
    fetch(${process.env.EXPO_PUBLIC_STRAPI_API_URL}/api/user-courses, {
...
----- 
But that should be OK. We need not do any optimization here.

CG: Exactly ✅ — no optimization is needed here.

These APIs run server-side in the Expo router (+api.ts route handlers), so:

  1. The process.env.EXPO_PUBLIC_STRAPI_API_URL is read safely on the server, not exposed to the client.

  2. Each API call only happens when the API endpoint is invoked — there’s no repeated or unnecessary fetch at module load time.

  3. 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.

  4. 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.

--------------------
[The above code is for future, as now I want to go the tutorial route and create a production APK that always uses one (production) backend URL. So current .env setup is OK.

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.

===================================================
18:41, 14 October 2025

To do list:
On web, Home page image vertical clipping needs to be fixed. IFIRC, initially SG app had that issue. But later SG app does not seem to show that issue.

Done: Apple login button needs to be hidden.

Done: 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. ... https://raviswdev.blogspot.com/2025/04/notes-on-learning-stage-of-developing.html (part 1) states that file lib/getStrapiMedia.ts got added as a fix to image not being shown on home page, and that it was suggested by ChatGPT. It is not mentioned in part 2 and part 3 blog posts.
GitHub history shows that it renamed from getStrapiMedia.ts to getStrapiMedia.ts.txt in Commit a9895ff, May 27, https://github.com/ravisiyer/SG-EduApp-LMS/commit/a9895ffb4528d6254be7a0c3de6b7a99aa4a5765 . I think that confirms it and so I have deleted the file and lib folder which has only that file.

Profile page on web and mobile does not handle dark mode.

Done: Also remove waitlist in .env
-------------------------------

On login on web, console shows:
C:\Users\{username}\NIBSProj\SG-EduApp\lms\app\login.tsx:15 Cross-Origin-Opener-Policy policy would block the window.closed call.
(anonymous) @ entry.bundle?platform=web&dev=true&hot=false&transform.routerRoot=app:125373
setInterval
---snipped as it is long and seems to be repeated 6 times ---
C:\Users\{username}\NIBSProj\SG-EduApp\lms\app\login.tsx:46 Error: Missing required fields
    at handleSignInWithSSO (C:\Users\{username}\NIBSProj\SG-EduApp\lms\app\login.tsx:31:17)
---------------
But the login goes through.
On Android, this message is not shown.

Had a longish exchange with CG on first issue above. Finally CG provided a summary note for the first issue (which I edited a little to bring in the localhost example):

⚙️ 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:

Cross-Origin-Opener-Policy policy would block the window.close() call.

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’s https://accounts.clerk.com) for authentication.

  • After successful login, Clerk redirects the popup back to your app — e.g. http://localhost:8081?/sso-callback?.... or https://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:

const { createdSessionId, setActive, signUp } = await startSSOFlow({ strategy });

  • 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, signUp is likely undefined.

...

[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.

===================

git commit -m "Commented out Apple from login page" 

https://clerk.com/docs/reference/expo/use-sso explains signIn and signUp return objects.

Me: The clerk ssoflow code doesn't work ...
        // In debugging, I found that if user is only signing in, signUp is not null
        // but signUp.id is undefined and signUp.status (also _status) is null
        // https://clerk.com/docs/reference/javascript/sign-up#properties states that 
        // status 'complete' means:
        // The user has been created and the custom flow can proceed to setActive() to create session.

But when the user signs in with a new id not yet registered in app, signUp is still having status as null.
...
Me: I got why the signUp was not going through .. I used ek... which I had used to test the waitlist invite feature and had denied the invitation to ek.... Clerk remembers that and does not provide a way to remove it. So even though I have removed the waitlist feature from env var. ... wait a sec, i removed env var. setting but not in Clerk itself.  .... Now I changed sign up mode in Clerk dashboard from waitlist to public. ...
...
Me: This time around the Clerk SignUp succeeded (and user got added to STRAPI backend as well). Still our code needed a change ...
From console, signUp object (when user got created in Clerk):
{id: 's--snipped--', pathRoot: '/client/sign_ups', _status: 'complete', requiredFields: Array(0), missingFields: Array(0), …}
signIn object in this case: tl {id: 's--snipped--', pathRoot: '/client/sign_ins', _status: 'needs_identifier', supportedIdentifiers: Array(0), supportedFirstFactors: Array(4), …}

....
The code change I introduced:
...
        if (signUp && signUp.status === 'complete') {
          const email = signUp.emailAddress;
          const username = email;
          const password = randomUUID();
          const id = signUp.createdUserId;
....

Me: I logged out and logged in with the newly signed up user. So now it is a login (signIn) and not signUp. Console data:
signUp object: th {id: undefined, pathRoot: '/client/sign_ups', _status: null, requiredFields: Array(0), missingFields: Array(0), …}
signIn object: tl {id: 's--snipped--', pathRoot: '/client/sign_ins', _status: 'complete', supportedIdentifiers: Array(0), supportedFirstFactors: null, …}
....

Me: One issue here is we are guessing how Clerk indicates this based on data that we see in debugging. Clerk docs itself do not seem to clearly say that - see https://clerk.com/docs/reference/javascript/sign-up#properties
Google search AI says:
To determine in your application code whether a user has just signed up or signed in after using Clerk's startSSOFlow, you can leverage the useUser() hook and potentially user metadata or webhooks.
1. Using the useUser() hook:
...
2. Using User Metadata:
A more robust approach involves using Clerk's metadata system:
...
3. Using Webhooks:
...
=== end Google Search AI message ==================

But that is quite complicated!
...

Me: For this tutorial, I am fine with our debug heuristic approach, I think. But please provide me a summary note covering this simpler method and the more complex official methods. But please confirm the official methods using Clerk docs and give suitable references.

CG: Certainly! Here's a concise summary of the methods available to determine whether a user is new or returning in Clerk, along with their respective advantages and limitations.

🔍 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 === null suggests 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 isNewUser field 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!

---------

Changing waitlist to public in Clerk dashboard seems to have deleted all denied invitations.
-----

Me: While signUp case now works it shows following errors in console:
clerk.browser.js:1 Cannot initialize Smart CAPTCHA widget because the `clerk-captcha` DOM element was not found; falling back to Invisible CAPTCHA widget. If you are using a custom flow, visit https://clerk.com/docs/guides/development/custom-flows/bot-sign-up-protection for instructions
overrideMethod @ hook.js:608
---snipped----
v1?ray=98e9184c180e7f8b&lang=auto:1  Note that 'script-src' was not explicitly set, so 'default-src' is used as a fallback.
B5 @ v1?ray=98e9184c180e7f8b&lang=auto:1
---snipped----
----
Also console tab is replaced automatically by sources showing localhost:8081/index page beginning

CG: Ah! These console messages are expected in certain scenarios when using Clerk’s custom sign-up flows, especially with SSO and bot protection. Let me break them down:

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 div with id="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-captcha element if you want the Smart CAPTCHA visible for bot protection.


...
----------
Commit: Fixed Clerk signIn console error - Used unofficial simple means to differentiate correctly between Clerk signIn and signUp
------------

13:01, 15 October 2025
To do list:
On web, Home page image vertical clipping needs to be fixed. IFIRC, initially SG app had that issue. But later SG app does not seem to show that issue.

Profile page on web and mobile does not handle dark mode.

After above, production deploy can be considered.
With prod. deploy change below env var in .env.cloud.fs:
# EXPO_PUBLIC_WEB_SOON_URL=http://10.50.171.151:8081/soon
=====================

16:50, 15 October 2025

Commit: Added dark mode support for profile page; Works on web and mobile
Done: Profile page on web and mobile does not handle dark mode.
----

In HomeBlock.tsx:
  • <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).
Noted that build-failed-lms-react-native (SG repo code) has:
  • <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
Perhaps ideally, we need separate images for mobile and web with the web image being more suitable for a wider screen. By using the same image for both mobile and web, we are struggling to get it right on both platforms. Am I right?

[CG agreed to above.]

The Android app does not switch to landscape mode. Is that the default for React Native app?

[CG said yes] 

Me: Interesting! So the norm is that for RN apps, we use only portrait? (Unless there are specific requirments for landscape support)? .... Is that the norm for android apps in general? ... I just checked my two ISPs Android apps - Jio and Airtel. Both do not support landscape mode.

CG: ... 

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:

android:screenOrientation="portrait"

in their AndroidManifest.xml.


⚖️ When Landscape Support Makes Sense

App TypeOrientation Strategy
Video / StreamingPortrait + auto-rotate to landscape for player
GamesLandscape (default)
Reading / Social / Telecom / BankingPortrait only
Maps / NavigationBoth orientations allowed
Dashboards / ChartsOften 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.

https://youtu.be/fO3D8lNs10c?t=11830 and https://youtu.be/fO3D8lNs10c?t=12028 show web image being squeezed in place but whole image shown.
So looks like the tutorial author changed the code somewhere down the line to allow the image to be squeezed (aspect ratio changed) but show whole image.

Coming back to the tutorial app, I think the tutorial author chose a suitable image for mobile 
...
The solution was arrived it through discussion with CG involving quite a few iterations and some trials from me.
...

CG: 

1️⃣ What aspect-video does in NativeWind / React Native

  • aspect-video is a predefined utility in NativeWind (borrowed from Tailwind CSS)

  • It sets aspect ratio = 16:9, i.e.:

height=width16×9\text{height} = \frac{width}{16} \times 9
  • On mobile (portrait): width = screen width → height = 9/16 × width

Example:

DeviceWidth (px)Height with aspect-video (px)
Small phone360202
Medium phone414233
Large phone768432

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:

  1. Hero images / banners often use 16:9 or 4:3 aspect ratio.

  2. Video or media content almost always uses 16:9.

  3. 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-video makes 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.


...
CG: On mobile portrait:
  • w-full ≈ w-screen most of the time.

  • w-screen can overflow if parent isn’t full width.

  • w-full is safer and more predictable.

...

Me: stretch did the trick. ....
Now mobile shows well.
Web shows stretched image (like later part of tutorial) for window width greater than mobile width.
So far so good. I am happy with this.
But one edge case ... On web, if user reduces window width to below mobile width, the app continues to use stretch unless user refreshes the window at which time it switches to contain. The resultant screen is also OK. No problem.  
But if then the user increase width of web window to beyond mobile width, the image expands vertically and the text below image goes off screen. Refresh fixes the issue.

If we want to fix this, we need to listen to window width change event and trigger re-render of this screen if it moves from mobile width to greater than mobile width (or vice-versa).
what do u say?

[CG agreed and provided some code which I used.]

-----------

Above interim approach was similar to later part of tutorial video:
Commit: Fixed home page image vertical clipping on web by using stretch for beyond mobile width
...

Then I got an improved approach combining maxHeight and resizeMode contain:
In HomeBlock.tsx:

  const [windowWidth, setWindowWidth] = useState(Dimensions.get('window').width);
  const [windowHeight, setWindowHeight] = useState(Dimensions.get('window').height);

  useEffect(() => {
    const onChange = ({ window }: { window: ScaledSize }) => {
      setWindowWidth(window.width);
      setWindowHeight(window.height);
    };

    const subscription = Dimensions.addEventListener('change', onChange);
    return () => {
      subscription.remove();
    };
  }, []);

  const isMobileWidth = windowWidth < 768;
  const maxHeightStyle = { maxHeight: windowHeight * 0.5 };
...
        <Image
          source={{ uri: homeInfo?.image }}
          className="w-full aspect-video"
          resizeMode="contain"
          style={maxHeightStyle}
        />
...
----------------

CG: Nice — this is a solid, pragmatic solution and I like how you simplified things for the tutorial while still handling the important edge cases. ✅

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 on Platform.OS for 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 numeric maxHeight (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.

...

Me: Great input. As of now, I will go with current code as on my PC, the window resize events seem to be getting handled well. Noted other points.

Commit: Improved home image display on web by adding 50pc window height limit and using contain not stretch

Me: Now, especially on web, I can't be sure of window height being some minimum size. So having a vertically scrollable screen for HomeBlock.tsx will be good. Can you modify the above to have a vertically scrollable screen when content spills over vertically?

[CG suggested simple code change which I used.]

Me: The scrollview code you gave is working very well. Not only on web on PC when I reduce the window height but also in landscape mode on mobile browser! That's really satisfying to see. Thanks a ton.

Commit: Wrapped content of home page in Scroll View

[Then I shared app\(app)\(authenticated)\_layout.web.tsx code with CG.]

Me: When used in narrow width web browser (including mobile browser in portrait mode), as you already mentioned above, there is an overlap or clipping issue. I don't want to go for a collapsible top level menu as there is already a lesson list drawer on the left side in web app which collapses below a certain window width.

Instead, I want to clip the link text to strings like 'My St..' 'Brow..' 'My Co..' 'Prof..' . Now I can use Window width and listen to window width change like in HomeBlock.tsx but is there a simpler CSS media query possibility or NativeWind breakpoint declaration (like in Tailwind CSS) like in React web apps.

[CG provided some code using sm breakpoint.]

Me: I think I need it only for below 500 px width. In React web IFIRC, I can specify that in Tailwind. Can I do that here?

[CG gave changes to be made to tailwind config to support custom breakpoint of xs which I set to 500 px.]

Commit: For web, used NativeWind media query breakpoints to fit menu below 500px width

Me: Reducing gap and keeping font size of My Studio as normal for below xs was enough to fit the entire menu with 360 px window width.
truncate does not seem to work (as on web I could reduce the width to even smaller at which time right menu items get clipped. So I will drop truncate (adds ineffective code).

I made the code changes and tested. The changes worked well. But I did not commit at this stage.
...

Me: next file is app\(app)\(authenticated)\course\[slug]\[lesson]\overview.tsx ... Pls study the code given below:
[I shared file code with CG.]

Me: On my Android phone, the image stays in place on vertical scrolling. It is only in web that it becomes smaller and then disappears as I scroll down vertically.
...

Me: The scale and translateY animations make it feel like a parallax, but the container keeps the height, so the image never disappears.
I dont see any animation in the image on Android at all.
...

Me: what exactly is this parallax effect

CG: The parallax effect is a visual trick used to create a sense of depth in a 2D interface. It happens when different elements move at different speeds relative to the user’s scroll or movement. In your case:

In your course header example

  • The header image is absolute and animated.

  • As you scroll down the page:

    • The image scales slightly (scale increases or decreases).

    • The image moves slower than the content below (translateY is 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

  1. Different scroll speeds – background moves slower than foreground.

  2. Sense of depth – our brain interprets slower-moving objects as being farther away.

  3. 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.value increases (scrolling down), translateY moves 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.


So in short: parallax = background moves differently than foreground → creates depth and a dynamic scrolling experience.
...

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: What a great explanation you have provided and related it to my observation and language. I think I have understood the basics of parallax effect now. U r a great teacher!
...

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 your scale / translateY transforms 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! ...

[This discussion was a very extended one covering many different attempts to get parallax effect in this overview.tsx to work with Android. Later, I stumbled upon the solution which was there in one of SG repo files but missing in SG repo overview.tsx. I have given that part in the main notes below. I have moved most or all of the other notes about these attempts which I now view as non-critical to Non-Critical Notes section.]

========================
12:07, 16 October 2025
Temp. saved failed Android parallax code in app\(app)\(authenticated)\course\[slug]\[lesson]\overview.tsx as overview-parallax-attempt.tsx.txt.
Then reverted overview.tsx to what it was before attempts to get parallax to work on Android.

Commit: Improved web menu responsive code at below custom xs breakpoint of 500 px

---- Start May Not Be in Right Order ----

At https://youtu.be/fO3D8lNs10c?t=8655 SG starts showing how to use @dev-plugins/react-query in React devtools to debug react query stuff. I should explore this later on.
---------------------

From https://youtu.be/fO3D8lNs10c?t=8898 SG starts (see below link for first time he shows the parallax effect) part of implementing parallax view (first time in video, I think) in app\(app)\(authenticated)\course\[slug]\index.tsx  [not course overview, which probably may be later on]
https://youtu.be/fO3D8lNs10c?t=9235 - First time, AFAIK, SG shows parallax effect.

---- End May Not Be in Right Order ----
---------------------------------------

[Made fresh additional efforts to get parallax effect in this overview.tsx to work with Android, starting with some Google Search AI response on the matter. I have moved most of these attempts to Non-Critical Notes section]

===========================
Sometime after 19:41 on 17 October 2025
...
Commit: Has Android parallax effect related commented code but app behaviour is like immediately previous commit

...
During testing of app behaviour I saw the solution in course screen shown by Browse Courses! Noted that that screen code passes prop: dom={{ matchContents: true, scrollEnabled: false }}, in the RichtTextContent. Tried the same in overview screen after which parallax started working for that screen too in Android app.

3 files in build-failed-lms-react-native (repo) folder as well as in my project (prior to above fix) had useAnimatedScrollHandler (which I think is necessary for parallax effect):
  1. app\(app)\(authenticated)\course\[slug]\index.tsx
    • which uses dom={{ matchContents: true, scrollEnabled: false }} in its RichtTextContent component.
  2. 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
  3. app\(app)\(authenticated)\course\[slug]\[lesson]\overview.tsx
    • which does NOT use dom={{ matchContents: true, scrollEnabled: false }} in its RichtTextContent component.
https://youtu.be/fO3D8lNs10c?t=14443 shows that for the RichTextContent (repo code has RichtTextContent) element SG has dom={{ matchContents:true ... statement which is not there in the repo (and not in my code).
-----
Now I checked that https://youtu.be/fO3D8lNs10c?t=14443 shows overview.tsx[lesson] and I made the above note as in my project and SG repo overview.tsx the dom={{... line is not there.

So SG would have added that to project used in his video but not updated the repo with it.

While I vaguely recalled that this dom line is not there in some file in project repo but that SG video shows it, I do not recall SG making any statement that this was needed for parallax effect and so did not consider the dom={{ as the line needed for parallax to work.

Anyway, now it is clear that this is the needed fix. So I have added:
dom={{ matchContents: true, scrollEnabled: false }} as RichtTextContent component prop in app\(app)\(authenticated)\course\[slug]\[lesson]\overview.tsx.

Commit: Fix for parallax effect in Course Overview screen in Android; Code cleanup

==============================================================
==============================================================

Non-Critical Notes

[Contents below are about many different attempts to get parallax effect in overview.tsx to work with Android.]

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:

  1. Changing scrollRange to 50px for mobile

    • Suggested compressing the interpolation so small scrolls trigger noticeable parallax. ✅ Didn’t work; Android app still shows no movement.

  2. Adjusting scale and translateY values

    • Tried subtler ranges like [1,1.1] for scale, smaller translation distances. ✅ No visible effect on Android.

  3. Adding extra padding / content to ScrollView

    • I suggested increasing paddingBottom to create more scroll distance. ✅ You didn’t mention this being implemented or tested; still, it’s more of a test hack than a solution.

  4. Debugging scrollY.value

    • Suggested logging scrollY.value to see if scroll events are registering. ✅ Not clear if this showed any change on Android.

  5. Separate scrollRange for 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-reanimated behaves differently on Android vs web.

  • useSharedValue and useAnimatedScrollHandler might not be firing as expected on Android (perhaps due to the className="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

Me: Google Search AI on: react native and expo parallax effect works on web but not on android
gave some interesting info. One point it mentioned is:
2. react-native-reanimated Implementation:
Proper Setup: If you are using react-native-reanimated for your parallax effect, verify that the library is correctly installed and configured in your babel.config.js file.
JavaScript

    module.exports = {
      presets: ['babel-preset-expo'],
      plugins: ['react-native-reanimated/plugin'],
    };
Worklets and UI Thread: react-native-reanimated uses worklets to execute animations directly on the UI thread, which is crucial for smooth performance on native devices. If your reanimated code isn't correctly structured as worklets or is interacting with the JavaScript thread in ways that cause delays, it could manifest as an issue on Android.
---- end Google Search AI info. -----

On informing Google Search AI that: react-native-reanimated/plugin is is not added to my plugins array. But I am following a tutorial where the author is using it in iOS. he does not seem to add react-native-reanimated/plugin to plugins array in his babel.config.js. But it works for him. So is this requirement only for Android and not for iOS. ...
Additionally, I had to mention: The project does not use Expo Go. It needs an Expo dev client for development related testing of Android app.
------
Then Google Search AI responded:
Adding react-native-reanimated/plugin to your babel.config.js is essential for Android in a dev client setup, even if the tutorial author did not mention it for their iOS testing. The plugin is not automatically configured by babel-preset-expo when running with a dev client, and its absence is a common reason for Reanimated effects like parallax to fail on Android. 

Here's a breakdown of why this happens and how to fix it.
The difference between Expo Go and a dev client
  • 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 vs. iOS: The Babel plugin is necessary for Reanimated to correctly process "worklets," which are small JavaScript functions that run on the native UI thread.
  • 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. 
How to fix the parallax effect on Android

To make your parallax effect work consistently on your Android dev client, you must manually add the Babel plugin and rebuild your app.
Edit your babel.config.js file. Add 'react-native-reanimated/plugin' to the plugins array. It must be the last plugin listed.

module.exports = function(api) {
  api.cache(true);
  return {
    presets: ['babel-preset-expo'],
    plugins: [
      // Your other plugins, if any.
      'react-native-reanimated/plugin', // Must be listed last.
    ],
  };
};

Clear the Metro bundler cache. The bundler needs to be reset so it processes the new Babel configuration.
npx expo start -c

Rebuild your development client. After modifying the babel.config.js file, you must create a new build of your dev client binary for the changes to take effect natively.

For Android: npx expo run:android
Using EAS: eas build -p android 

Once the new build is installed on your Android device, the parallax effect should work correctly during your testing.
---------------------------
What's ur view on above? 

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:

  1. Log or throw inside a worklet

    const imageAnimatedStyle = useAnimatedStyle(() => { 'worklet'; console.log("Inside worklet, scrollY:", scrollY.value); // … });
    • If this console.log does not run when scrolling (on Android), then the function is not being executed as a worklet — meaning the Babel plugin probably isn’t applied.

  2. 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 logcat or in React Native console) for that message.

...

Me: Metro does not say anything about worklet.
...
Me: the console message appears both on web and Android. Here's the related Metro console data for Android:
 (NOBRIDGE) LOG  Mapped courseId to userCourseId: vjcisu67y1r7x2kmbvm2rqlh
 (NOBRIDGE) LOG  ✅ Worklets ARE running on the UI thread
DOM Bundled 11ms node_modules\expo\dom\entry.js (1 module)
...

CG ... So this confirms something important:

Worklets are running correctly on Android.
Your react-native-reanimated setup (including Babel transform and native module) is fine.

That means the parallax code logic or the event that drives 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).....] 

...
Me: After a break, I feel I should have another crack at this issue but this time with me getting deeper into the code. Firstly, I want to get the scoll value to get updated on web display. In the following code, scroll value always shows as 0 but that's because the component is not getting re-rendered on scroll event. In web case, the console.log statement in the scroll event handler shows the correct value in the browser console. ...

CG: ...

Option 1 — Use a React state synced with the shared value

const [scrollYDisplay, setScrollYDisplay] = useState(0); const scrollHandler = useAnimatedScrollHandler({ onScroll: (event) => { 'worklet'; scrollY.value = event.contentOffset.y; runOnJS(setScrollYDisplay)(event.contentOffset.y); }, });

Then in your JSX:

<Text className='dark:text-white'> {`scrollY.value: ${Math.round(scrollYDisplay)}`} </Text>

✅ 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.]

----
After changing babel.config.js, always restart Metro with cache cleared:
Ran: npx expo start -c

Not necessary all times and so skipped: npx expo prebuild --clean

-----
Me: Ran: npx expo run:android
...
BUILD SUCCESSFUL in 1m 19s
...
Uninstalled old debug apk from android phone device and installed newly created debug apk.

No luck! ScrollY.value does not change on scrolling. No parallax effect.
...

Me: Should I try with npx expo prebuild --clean and then build again.

CG: Yes — in your current dev client setup, running npx expo prebuild --clean is a good idea.

Here’s why:

  1. Clears cached native builds: Sometimes Android retains old native modules or configurations (especially after changing babel.config.js). --clean removes previous build artifacts.

  2. Ensures Reanimated plugin is fully applied: After adding 'react-native-reanimated/plugin' to babel.config.js, a clean prebuild ensures the plugin is processed for the native Android build.

  3. Reduces hidden caching issues: Even if Metro shows ✅ Worklets ARE running, Android native code might still be out-of-sync. Cleaning avoids subtle issues.

...

Me: Ran: npx expo prebuild --clean
Ran: npx expo start -c
Stopped above server.
Ran: npx expo run:android
...
BUILD SUCCESSFUL in 7m 2s
...
Uninstalled old debug apk from android phone device and installed newly created debug apk.

Had commented out following to avoid any issues:
      // runOnJS(setScrollYDisplay)(event.contentOffset.y);
      // runOnJS(console.log)('📜 scrollY:', event.contentOffset.y);
So now I can only check for parallax effect.
No luck. No parallax effect.

But this confirms that the babel.config.js stuff is not the problem here. We have done a proper rebuild. Now we can focus on other possibilities.

CG: Exactly — since you did a full clean prebuild and rebuilt the dev client, we can now rule out babel.config.js / Reanimated plugin issues. The problem lies elsewhere. ...

Me: Strange thing I noticed now ... In:
      {/* Course Info Section */}
      <View className="px-4 pt-4 bg-white dark:bg-black flex-1">
        {/* Debug scroll position display */}
        {/* <Text className='dark:text-white'>
          {`scrollY.value: ${Math.round(scrollYDisplay)}`}
        </Text> */}
        <Text className="text-2xl font-bold text-gray-800 dark:text-white">{course.title}</Text>
        <View className="flex-1 py-4 min-h-[100px]">
          {course.description ? (
            <RichtTextContent 
              colorScheme={colorScheme}
              blockContent={course.description} 
            />
          ) : (
            <Text className="text-center text-gray-500 dark:text-gray-400">
              No description for this course.
            </Text>
          )}
        </View>
      </View>
------
In Android app, The course title line does not scroll out of view. Only the course description part scrolls.
On web, the course title and description scroll up together .

[CG suggested some style changes that did not help.]
...

========================

Me: Let me try dummy content instead of RichtTextContent component. Let us see whether with dummy content we can fix the problem. what do u say?

CG: Yes, that’s a great idea. 👍
By replacing 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:

  1. Each <Text> line ensures there’s enough height to scroll.

  2. No flex-1 is used in this block—this avoids Android ScrollView sizing issues.

  3. Keep the surrounding <Animated.ScrollView> unchanged.

After replacing, check:

  • ScrollView scrolls fully, including the title.

  • scrollY.value updates 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:

  1. Inspect RichtTextContent and see why it interferes with scrolling.

    • Check if it uses flex-1 internally or any nested ScrollViews.

    • Ensure it grows to the height of its content so the parent ScrollView can scroll.

  2. 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.]

Me: Commenting 'use dom' breaks the Android app - crash before coming to this overview page.
...
Me: RichtTextComponent is vital for the tutorial app. It is used throughout the app to render STRAPI rich text content in the app. So there is no choice but to use 'use dom' in RichtTextComponent.
....
In components\RichtTextContent.tsx , i used the following code:
// 'use dom';
import { BlocksContent, BlocksRenderer } from '@strapi/blocks-react-renderer';
import '@/global.css';
import { Text, View } from 'react-native';
import { DOMProps } from 'expo/dom';

const RichtTextContent = ({
    blockContent,
    colorScheme = 'light'
  }: {
    blockContent: BlocksContent;
    colorScheme?: 'light' | 'dark';
    dom?: DOMProps
  }) => {
  return (
    <View className={`${colorScheme === 'dark' ? 'bg-black text-white' : 'bg-white text-gray-800'}`}>
      {Array.from({ length: 45 }).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>
      ))}

      {/* // <BlocksRenderer content={blockContent} /> */}
    </View>
  );
};
export default RichtTextContent;
==========================
That did not break the Android app. Further the parallax effect work correctly.

But if uncomment the first 'use dom' line, even if I am continuing to use the dummy content (BlocksRenderer commented out), the Android app shows that content but the parallax effect is NOT there. 

I think this confirms that it is the 'use dom' statement that is breaking the parallax effect. What do u say?

CG: 💯 Yes — you’ve nailed it exactly.

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 — onScroll never fires beyond 0, 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.


...
Me: ... I need the BlocksRenderer content to be rendered on Android app too. I will simply have to accept that since I need BlocksRenderer which needs 'use dom' for the data in STRAPI backend, I cannot have parallax effect on Android.

CG: ✅ That’s a perfectly reasonable and technically sound decision.

You’ve analyzed it exactly right — and your reasoning is solid:

  • BlocksRenderer requires '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' and BlocksRenderer → rich content everywhere (Android + web)

  • Accept that the parallax header will only work on web, not on mobile native builds.

That’s an acceptable trade-off given your app’s requirements.
...

Me: Well, having the  parallax code in the app does not significantly impact the app. So I will leave it in place.

[CG agreed.]

===========================

[The following part is about how difficult it was to debug this parallax code not working on Android issue.]

Me: Hmm. That was a long debugging journey. Perhaps most of 1 to 2 work days. It was good to get the cause clearly. .... But it's a pity that it could not be debugged more easily. On Web, such an issue if specific to web could be caught much faster due to awesome debugging support. I mean the entire DOM tree is available not only for inspection but for temporary change and test. ... I guess with React Native on Android, such struggles are part of the norm. I mean, while I am a learner of React Native and Expo, still I think even more experienced React Native persons who have not encountered the 'use dom' and parallax issue, would have struggled to find out the cause (perhaps not so much as me, but still struggled somewhat). What's ur view. Please be frank.

CG: That’s a very fair and insightful reflection, Ravi — and you’re absolutely right on multiple counts.

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.

========================================================

19:41, 17 October 2025

Updated project code to have previous commit state of working app code on both web and Android, and having parallax on web but not on Android.

A) All dummy code used for parallax testing is commented out in following actively used in project files:
  • 1) (modified in current commit) RichtTextContent.tsx
  • 2) (modified in current commit) overview.tsx

B) Also additional .txt files are created:
  • 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 ----------
    • ...
C) One config file was modified to add a plugin but that does not seem to change the app behaviour in any way 
  • 6) (modified in current commit) babel.config.js
-----------------
Commit: Has Android parallax effect related commented code but app behaviour is like immediately previous commit
==

Cleaned up Android parallax related commented as well as some uncommented code that were printing console messages, and restored files to what they were before immediately previous commit that changed them. The changed files are:
1) RichtTextContent.tsx
2) overview.tsx
======================================

Comments