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

Last updated on 11 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.

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

16:10 25 September 2025

Restarting after a break
Restart point: Revenue Cat starting TS: https://youtu.be/fO3D8lNs10c?t=10412 (2 hrs. 53 min).

The remaining parts of the video:
02:53:31 Purchasing Courses with RevenueCat
03:13:36 Handling Web Purchases with Stripe
03:25:56 Showing Courses and Drawer Navigation
03:42:00 Building out the Custom Drawer
03:56:28 Creating Lessons with Expo Video
04:23:30 Adding a Waitlist Page and User Profile
04:30:58 Transfer Data to Strapi Cloud
04:37:38 Deploy your Website through EAS Hosting
04:41:27 Breakdown

As a first step in resumption after the break, I watched the whole of the remaining part of the video (close to 2 hours).

The 'Purchasing Courses with RevenueCat' part seems to need publishing Android app to Google Play Store which will need a Google Play account (paid). So I think I should explore skipping that part of the tutorial.

The 'Handling Web Purchases with Stripe' part does not seem to need any paid account/payment but if I am going to skip the RevenueCat part, perhaps I should skip the Stripe part too.

The parts 'Showing Courses and Drawer Navigation' to 'Adding a Waitlist Page and User Profile' seem very relevant for my needs/interests. So I must go through them and get the related code working for web and Android.

'Transfer Data to Strapi Cloud' part can be done using a trial period of Strapi Cloud (two weeks, IFIRC). The next part of 'Deploy your Website through EAS Hosting' seems to need the Strapi Cloud backend for the web deployment to work.
-----------------------------

I am considering incorporating the RevenueCat and Stripe parts related coding so that I follow the entire flow of the video. I will not invoke those parts by not using the paid course(s).  ... But if I trip up while taking this approach due to lack of Google Play account then I may have no option but to exclude related code of RevenueCat.

Interesting RevenueCat links:
---------------------------

Stripe related links and info.

15:58 26 September 2025

Stripe seems to have no-cost account creation. It seems to have a test mode where bank account info. is not needed. As per Google Search AI:
You can create a Stripe test account without bank information by signing up at Stripe.com, verifying your email, and then creating a new test account from within the dashboard's account menu, skipping the "activate payments" or business information steps if you don't want to activate the account. This provides access to your API keys and enables testing of Stripe features in a simulated environment without processing real money. 
-----
Above info. links to https://docs.stripe.com/get-started/account which says:
Immediately after you create a Stripe account, you can use your account in testing environments. In a sandbox, simulate transactions and use all of Stripe’s features without moving any money. To accept real payments, you must activate your account to use live mode. If you haven’t already, create a Stripe account.
---------------
Long winded video but seems to have main steps to create test account without providing bank info.: How to create Stripe Test Account | Stripe Payment Gateway | Spring Boot, 7 min. 8 secs., Dec. 2024.
For a regular account, bank account info. seems to be a requirement. After that, per payment, there are charges. 
More links:
=====================================================================

Created a login in revenuecat taking the option to skip providing card info.
Created a project SGlms. IFIRC, I had to choose one project type - Android or web (or some Apple OS). Chose web.
It gives a 'warning' message: "You have access to all the features for free, until you hit $2,500 in MTR."
So far, the procedure was quite smooth. Dashboard: https://app.revenuecat.com/overview

Am trying to create an account in Stripe now. Choosing India as country gives a message: "Stripe is available by invite only in India....". So changed country to United States which does not show such a message. ... Created account .... Now it showed a message box, "Welcome to Stripe India!
Activating your Stripe India preview invite" with a Get Started button! Closed the message box.
Now I am on the dashboard - https://dashboard.stripe.com/ - for my account with a red bar on top saying, "Test mode ... You're using test data. To accept payments, complete your business profile." ... It also provides a 'Complete profile' button with a message that IFIRC said I can receive payments only after completing profile. I presume I still can do testing and get 'fake' payments.
The API Keys (Publishable key and Secret key) are shown. Looks like my basic Stripe account for testing is in place now.
On verification of email, I skipped the profile part but got an option to get into some sandbox which I took and so now I am shown a message in header as: You're testing in a sandbox – your place to experiment with Stripe functionality.
...

Going through stripe Quickstart with backend as node.js (and front end as HTML) - https://docs.stripe.com/checkout/quickstart [By default backend is ruby. That selection has to be changed to node.js]

Downloaded project and ran npm install on it which succeeded.
Now before running it, I need to "In the back end code, replace {{PRICE_ID}} with a Price ID (price_xxx) that you created."
I think I first need to create a product in the sandbox.
Created 'Test product' price USD 19.99. It now has product id and price id. 
Used that price id in the back end code:
        // price: '{{PRICE_ID}}',
        price: 'price_XXXXX', // XXXXXX is not the actual id - I did not want to share that on this blog 
                                             //post even though it is a sandbox thing
     //as this whole stripe stuff is new to me and so I want to play safe.
Also confirmed that the test secret API key of code matches my dashboard-sandbox secret key.
======
Ran npm start
Then visited http://localhost:4242/checkout.html which showed a page. Clicking on checkout or something crashed the server. Main error message:
StripeInvalidRequestError: No such price: 'price_XXXXXXs'

After some trial-and-error rounds, saw that an s had got added to end of price ID by mistake. Removing it fixed the error and checkout resulted in payment by card page showing up!

https://docs.stripe.com/checkout/quickstart gives the dummy card info to be used for testing:
Payment succeeds 4242 4242 4242 4242
Payment requires authentication 4000 0025 0000 3155
Payment is declined 4000 0000 0000 9995
--------------

Using the 'Payment succeeds' card number with some dummy data for other fields, resulted in http://localhost:4242/success.html being shown with suitable message.

Tried http://localhost:4242/checkout.html again with 'Payment is declined'
Got a message on payment page itself showing the error: Your credit card was declined because of insufficient funds. Try paying with a debit card instead.

Back on page showed http://localhost:4242/cancel.html

Tried http://localhost:4242/checkout.html again with 'Payment requires authentication'. It showed Test authentication page with Fail and Complete buttons.

Fail showed payment page with message: "We are unable to authenticate your payment method. Please choose a different payment method and try again."

Trying it again, but this time clicking Complete, resulted in http://localhost:4242/success.html being shown.
---------------------
Hmm. Fascinating! This quickstart node.js app is running as expected with the sandbox dummy product and price. Quite satisfying to see those standard payment pages show up. First time for any app code build that I ran (as against production apps run from company websites or as android (production) apps).

The stripe dashboard shows two fake payments received and one failed, as expected.

============================================
11:41 27 September 2025

Official video: What is RevenueCat?, 1 min. 22 secs., published Dec. 2023
Official video: RevenueCat Products, Offerings, and Entitlements Explained, 5 min. 2 secs, published Aug. 2023.

From my post covering a discussion today with ChatGPT: ChatGPT: RevenueCat supports Android in-app payments only via Google Play Billing; Stripe is supported only for web (and server-to-server) payments.

In the above discussion, at the conclusion I wrote: As of now, as a learner I think the sensible thing for me is to simply skip the Android in-app payment part of the RN lms tutorial app. I can have the RN code set up to use RevenueCat but not process payments for Android (show some message about why it is not supported) and support it for web through RevenueCat which invokes Stripe.

ChatGPT agreed, saying it is a very practical plan. So that's what I intend to do. Note that I do not have a Google Play developer account as of now and so cannot host/publish the app (even on some test basis) on Google Play which is necessary for RevenueCat to support Android in-app payments.

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

16:06 30 September 2025

https://youtu.be/fO3D8lNs10c?t=10705 -> npm i react-native-purchases @revenuecat/purchases-js

react-native-purchases is for mobile. I may not need it but as I want to bring in all the code for app purchase too, I will need it for error free build.
@revenuecat/purchases-js is for web.

In app purchase, I think for active RC (RevenueCat) project,
yoga_1999_course_advanced is defined as a product

Under Apps:
Yoga LMS  is the App Store app.
LMS Web is a 'Payment provider'

https://youtu.be/fO3D8lNs10c?t=10755 : Entitlement  - Identifier: Advanced Yoga Mastery, Desc: Full Course Access - is attached to product.

https://youtu.be/fO3D8lNs10c?t=10776 Shows above entitlement associated with two products:
 a) yoga_1999_course_advanced , Yoga LMS (app)
 b) Course - Advanced (In gray below: yoga_1999_course_advanced), LMS Web (app)

He later detaches the web product - b) and then deletes LMS Web app.

https://youtu.be/fO3D8lNs10c?t=10805 -> Shows Offerings. It has only the "Default" offering.

https://youtu.be/fO3D8lNs10c?t=10810 -> Shows Package(s) with yoga_1999_course_advanced product (and also seems to mention entitlement with some $rc_lifetime...)

https://youtu.be/fO3D8lNs10c?t=10967 :
Then he brings in the RC provider code into the app, first for mobile. The code gets offerings info. from RC. So this code may expect offering, package, product, entitlement to be defined in RC. In other words, I cannot simply add the RC provider code for mobile and run app without having created offering, package etc. So I may need to delay running app only after I have the web provider code and have defined offering, package etc. for it in RC.

Also earlier, he adds EXPO_PUBLIC_REVENUECAT_IOS_KEY=appl_GvhUsqGCjgnVPIPRBoAjHdeQUfw
and
EXPO_PUBLIC_REVENUECAT_WEB_KEY=rcb_sb_n0ECRPeNzINYanMXWAFYUqEJO
as env variables. He must have got these values from RC but I had not noted it. [I am sharing these keys publicly as the video shares it publicly.]

https://youtu.be/fO3D8lNs10c?t=11139 -> Shows revenuecatid field in Strapi Course as yoga_1999_course_advanced

https://youtu.be/fO3D8lNs10c?t=11171 -> Shows him using a console window for the React native app. This is React Native Dev Tools Console. I think I need to invest some time understanding this.

https://youtu.be/fO3D8lNs10c?t=11395 -> Shows him using ngrok to localhost 1337 which gives him some url which he specifies as the STRAPI_API_URL:
EXPO_PUBLIC_STRAPI_API_URL=https://9f9e-2003-12-6f15-1896-d907-6810-25e9-31bb.ngrok-free.app
[He later says ngrok creates a tunnel to localhost STRAPI instance.]
 
https://youtu.be/fO3D8lNs10c?t=11422 -> Then he runs the mobile app using:
bunx expo run:ios --device
which he says will deploy his app to a device.

He says he had to create a sandbox user ... in RC or is it Apple Sandbox - https://youtu.be/fO3D8lNs10c?t=11503 ... https://youtu.be/fO3D8lNs10c?t=11552 indicates it is a RC sandbox user.

Looks like he then does the purchase on his device but whose screen is 'broadcast' to either the video directly or to his PC screen which is being video recorded. ... Why could he not do it from his emulator? Does the payment part of Apple expect it to be run from a real device and not an emulator?

The web part starts here: https://youtu.be/fO3D8lNs10c?t=11618

https://youtu.be/fO3D8lNs10c?t=11836 -> Covers connecting Stripe a/c to RC
He mentions some RevenueCat app to be installed (for Stripe/in Stripe, I guess) which he seems to have done earlier on itself.
He creates lms (web) app and then gets its sandbox API key and sets EXPO_PUBLIC_REVENUECAT_WEB_KEY in env file to it.
Next he creates a new product and uses same identifier as App store product (yoga_1999_course_advanced). He gives the title as "Advanced Yoga Course"
He then attaches it to the entitlement.
In the Offerings, he includes lms (Web) Advanced Yoga Course product.

https://youtu.be/fO3D8lNs10c?t=12047 -> Shows browser console of web app with RC Offering details.

19:28 30 September 2025
Before getting into trying out the code changes for RC, the first step was to run the app as it is now. Was able to run web app as well as Android app on phone (dev build verison IIRC). Have not checked the Android emulator part yet as anyway, the app testing for RC will be only on web.

https://app.revenuecat.com/ shows my SGlms project
https://dashboard.stripe.com/ is slow but eventually showed my sandbox a/c

Now am trying instructions in https://www.revenuecat.com/docs/web/connect-stripe-account to connect RC to Stripe.
Step: Log into the RevenueCat dashboard and click the Connect Stripe account button in your account settings. 
Took me to https://marketplace.stripe.com/apps/revenuecat . Clicked Install App -> Chose 'Install app in Sandbox' in dialog that opened. Got a message that Install was successful.
Then it showed my Stripe sandbox but with a RevenueCat panel on the right which had a 'Sign in with RevenueCat' button. That is the next step in the procedure. So clicked on it.
Clicked on 'Link Stripe account' button in next screen.

Now https://app.revenuecat.com/settings/account shows 'New business sandbox' under Stripe accounts. [Next time, I should explore providing meaningful name for it.]

The instructions page says, "After you connect your Stripe account, make sure that you select the correct account in the platform configuration page:"
But I am not able to see such a platform configuration menu link/button. So I am skipping this step.
Now I will go through SG video and try to create web product, package and offering.

The UI of RC now seems to be quite different from SG video.
To add web billing, I am trying: SGlms (project) -> Apps & providers -> 'Add web config' button.
In next screen, I choose Web Billing (Web subscriptions by RevenueCat).
That takes me to "New Web Billing configuration" screen which has some match with SG video: https://youtu.be/fO3D8lNs10c?t=11849
I selected Stripe Account of New business sandbox from dropdown.
App Name: Yoga Studio
Support email: riyer02@gmail.com
Then clicked on 'Save Changes'.
I got a message 'App created successfully'.

Picking up my Sandbox API key: --snipped--
Now in .env in lms app:
EXPO_PUBLIC_REVENUECAT_WEB_KEY=--snipped--
----

In SG lms (project) -> Product catalog -> Products -> Clicked 'New Product'
Next screen: Choose an App ... Select one of the Apps within this Project to add the product to.
Chose the only option of SGlms (Web Billing)
That took me to: New Product screen which has some match with https://youtu.be/fO3D8lNs10c?t=11932
Identifier: yoga_1999_course_advanced
[This identifier matches STRAPI Course revenuecatId field - see https://youtu.be/fO3D8lNs10c?t=11139 ]
Screen does not have Title (which SG video has) but has Name (which SG video does not have)
Name: Advanced Yoga Course
Display name: Advanced Yoga Course [Display name is shown above Name in screen]
Description: PRO Access
Product type: Non-consumable
Pricing -> USD (Default) -> Base price: 19.99
Clicked on 'Create Product'
Product got created.
---------

Now I need to add entitlement. SG video shows an entitlement already created in iOS app part itself.
SGlms -> Product catalog -> Entitlements -> Clicked 'New Entitlement'
Based on https://www.youtube.com/watch?v=fO3D8lNs10c&t=10755s , gave the following:
Identifier: Advanced Yoga Mastery
Description: Full course access
Clicked 'Add'. That created the entitlement.

Now in SGlms -> Product catalog -> Entitlements -> Advanced Yoga Mastery :
clicked Attach for Associated products.
Checked the only entry listed of SGlms (Web billing) app, Product: Advanced Yoga Course yoga_1999_course_advanced, Type: One-time purchase
Clicked 'Attach'
It went through. It showed RevenueCat ID as --snipped--


Now I need to create an Offering.
In SGlms -> Product catalog -> Offerings -> Clicked 'New Offering'
Followed values in https://www.youtube.com/watch?v=fO3D8lNs10c&t=11836s 
Identifier: Default
Display Name: Standard set of courses
(SG video shows Description but I am not shown that column and so I used Display Name).
This screen provides a 'New Package' button. I need to create package as well. So before Save on this screen, I clicked on 'New Package'.
Followed values in https://youtu.be/fO3D8lNs10c?t=12007
Identifier: Lifetime (from dropdown)
Description: Advanced Yoga Mastery Course
Product: Advanced Yoga Course (yoga_1999_course_ad...) [Selected from dropdown having only one entry]
Clicked Save. That created the offering Default with RevenueCat ID: --snipped--
and having the specified package.

Hmm. I think now I have all the elements: Offering, Entitlement and Product for RC Web billing mapped to my Stripe sandbox a/c.

Now I can get to the app code stuff.
...
Copied RC provider files from build-failed-lms-react-native\providers (has SG GitHub repo code) to lms\providers

22:08 30 September 2025
lms> npm i react-native-purchases @revenuecat/purchases-js

added 4 packages, and audited 1236 packages in 16s

109 packages are looking for funding
  run `npm fund` for details

4 vulnerabilities (3 low, 1 critical)

To address all issues, run:
  npm audit fix

Run `npm audit` for details.
lms> 
--------------
lms> npm audit fix

changed 6 packages, and audited 1236 packages in 3s

109 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
lms> 
--------------

Uncommented in .env
EXPO_PUBLIC_REVENUECAT_IOS_KEY=
EXPO_PUBLIC_REVENUECAT_ANDROID_KEY=
----

[Search for 'About specifying clerkId in RevenueCat Purchases' later on in this post (1 Oct. 2025 entry) for a more detailed discussion on the few lines note below.]

SG Video has in RevenueCatProvider.tsx - https://youtu.be/fO3D8lNs10c?t=10922
const {user} = useUser();
---
But that line is not in the same file in SG GitHub repo. https://github.com/Galaxies-dev/lms-react-native/blob/main/providers/RevenueCatProvider.tsx

Same for 
await Purchases.logIn(user!.id);

https://youtu.be/fO3D8lNs10c?t=10866 shows
import {useUser} from '@clerk/clerk-expo';

He says that this code connects the clerkId with RevenueCat (data).
As of now, I am skipping making these changes.

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


Development and Testing
Webhook Testing: Run ngrok on your local machine to get a URL to receive webhooks directly in the app you’re developing. Inspect and replay requests for fast development.
Local Previews: Demo a website running on your local machine to a client or stakeholder without deploying to a staging site.
Mobile Backend Testing: Test your mobile apps against a backend that you’re developing on your local machine.
--------

Use NGROK | Access your localhost from anywhere #ngrok #localhost #learning #learningisfun, https://www.youtube.com/shorts/2wWUjDaBmHo?feature=share , around 1 min., April 2024.

Another similar YT short for a React app: https://www.youtube.com/shorts/hdbCwTwtgow?feature=share
---------

Shows using ngrok within a node.js API server: https://www.youtube.com/watch?v=PjVZsggt4Ko , 7 min. 27 secs., Jan. 2024.

Later I should read up on security risks of using ngrok.
==========================

Gone through coding changes for mobile app related purchase part. As there is no IOS or Android app key, the code will fail. So I need to add suitable dummy code to prevent code failure. After that I can test the code (mobile app related purchase). And after that move to web app purchase part.

================================
08:52 01 October 2025

Saved code changes as commit: Code changes done for mobile in-app purchases but which will not work as we don't have RevenueCat IOS or Android keys

Made first round of code changes to get app to work returning dummy values for RC provider functions. So clicking on Purchase does nothing now for both web and mobile. Commit: "App works for mobile and web with dummy values returned by RevenueCat provider functions"

Improved handling of case of absent RevenueCat IOS or Android keys. Tested on Android. It is working well.
Commited with above message.

Have to do similarly for web.

...

13:24 01 October 2025

Commit: Handled case of absent RevenueCat web key. Works but messaging can be improved.

The web app uses sonner package for toasts.

https://www.npmjs.com/package/sonner/v/2.0.0-beta.0 - "Sonner is an opinionated toast component for React."

Did: npm install sonner

https://sonner.emilkowal.ski/getting-started#render-a-toast gives simple example of using toast method.

Commit: Improved messaging using toast for absent RevenueCat web key

Now I have to try web purchase using RevenueCat web key.

15:50 01 October 2025
After a couple of bug fixes, got payment screen (sandbox) to appear on Purchase course on web. The dummy/sandbox payment of US$ 19.99 went through but it seems that it has not added the user to the course.

I did not know about checking Sandbox at bottom right of Overview and so thought that https://app.revenuecat.com/  does not show any revenue. Later I figured that out and saw that it was showing revenue (and payment info).
https://dashboard.stripe.com/ shows the payment for today - $19.99 with time as 10.18 which seems to be behind by 5hrs 30 mins (and so must be GMT).

IFIRC, SG faced a similar problem when he made the first dummy purchase in iOS app.
https://youtu.be/fO3D8lNs10c?t=11456 : A little while into it, he says, "I had to create a new sandbox user to try this".
https://youtu.be/fO3D8lNs10c?t=11525 : Few secs later you see the added entry in User-course in strapi. But a new transcation is not shown in customer list in RevenueCat. He shows how to get user id from debug console and specify it in RC and then see associated customer profile - https://youtu.be/fO3D8lNs10c?t=11556
It just struck me that perhaps for web payments it is significantly different. So now trying to debug the code.
Retrying purchase gives an error from Stripe that user has already purchased the course!
I am trying to delete the transaction in Stripe. It gives a refund option for the payment but can't locate a delete option.

Discussed above with ChatGPT. It said:

Let’s separate out the pieces:

1. Stripe vs RevenueCat dashboards

  • Stripe shows the raw payment (customer → Stripe → your account).

  • RevenueCat shows entitlements (whether the user has access to the course).

If you see the charge in Stripe but no entitlement in RevenueCat, it usually means:

  • RevenueCat did not receive or process the webhook from Stripe, OR

  • RevenueCat isn’t properly configured for your product (SKU mismatch or Stripe product not linked correctly in the RevenueCat dashboard).

That’s why the user paid, but isn’t “granted” access in your app.


2. Time difference

Yes — Stripe shows times in UTC (GMT) by default. That’s why it looks ~5h30m behind IST. That part is expected and nothing is wrong there.


3. Retrying purchase → Stripe error

When you retry, Stripe says “already purchased” because that customer’s email/payment method has an existing successful charge.

  • Stripe won’t allow duplicate payments for the same subscription/product if you’re trying to recreate it.

  • For one-time payments, a new charge should normally go through, but in test mode with the same product ID it can block duplicates depending on setup.


4. Deleting vs Refunding in Stripe

Stripe does not allow deleting a payment transaction, even in test mode.

  • The only options are:

    • Refund (full or partial) → puts money back to customer (in test mode it just simulates it).

    • Void (for some pending payments, before they settle).

  • In test/sandbox, refunding is the way to “reset” things.

So yes ✅ you can refund that sandbox payment — that’s the correct way to clean it up.


🔑 What you should do next

  • Refund the sandbox payment in Stripe (safe, it won’t affect real money).

  • Double-check RevenueCat → Stripe integration:

    • Go to RevenueCat dashboard → Integrations → Stripe.

    • Make sure the webhook endpoint is set up in Stripe.

    • Ensure your product IDs match exactly between Stripe and RevenueCat (case-sensitive).

  • Check RevenueCat logs (there’s a debugging tab in their dashboard) to see if the event from Stripe was even received.


--- end CG response ---

So am trying refund in Stripe now.
Refund went through but faced the same problem in app that I have already purchased Advanced Yoga Course.
I can delete the customer I think. let me try that now.

Even after delete customer, I am facing the same problem. Is some caching coming into play here. Restarting app.
That didn't help. Same problem.

CG said: Got it 👍 — so deleting the Stripe customer alone wasn’t enough. That points to the fact that RevenueCat is still holding on to the entitlement for your test user ID.
...
For your current block, I recommend first trying deleting the test user in RevenueCat — that’s the cleanest fix.
------------

I could not find a search box for customer in RevenueCat....
RevenueCat shows the transaction! I had to turn Sandbox switch to on, in right bottom of RevenueCat project Overview.
RevenueCat (RC) also does not provide delete transaction. But I can delete the customer.
Deleted the customer in RC.
Now when I try Purchase in the app, it shows me the payment page.
----

The bug about no STRAPI User-course entry is in the third line in snippet below from index.web.tsx :
        if (
          result &&
          result.customerInfo.entitlements.active[productPackage.webBillingProduct.title]
        ) {
          const result = await addUserToCourse(course.documentId.toString());
-----
console log shows:
productPackage: webBillingProduct.title: "Advanced Yoga Course"

result.customerInfo.entitlements.active:
    "Advanced Yoga Mastery": {
        "identifier": "Advanced Yoga Mastery",
        "isActive": true,
        "willRenew": false,
        "store": "rc_billing",
        "latestPurchaseDate": "2025-10-01T11:34:25.000Z",
        "originalPurchaseDate": "2025-10-01T11:34:25.000Z",
        "expirationDate": null,
        "productIdentifier": "yoga_1999_course_advanced",
        "productPlanIdentifier": null,
        "unsubscribeDetectedAt": null,
        "billingIssueDetectedAt": null,
        "isSandbox": true,
        "periodType": "normal",
        "ownershipType": "UNKNOWN"
    }
}

So property name in active is: "Advanced Yoga Mastery"
But result.customerInfo.entitlements.active[productPackage.webBillingProduct.title] is indexing by "Advanced Yoga Course"
So the test fails and the course does not get added to STRAPI User-course

In my initial viewings, I had noted that somewhere in the video, SG does say something about mismatch but I did not catch it clearly. In a later viewing, I was able to understand this issue better.
https://youtu.be/fO3D8lNs10c?t=11942 shows SG adding Product for web. He specifies the Title field as "Advanced Yoga Course".
SG faces the same issue of course not getting added to STRAPI User-course.
https://youtu.be/fO3D8lNs10c?t=12252 shows him explaining the fix. He changed the Title field for the web Product to "Advanced Yoga Mastery" (same as in entitlement). That solves the issue and he shows how purchasing a course on web leads to a new entry being created in STRAPI User-course.

What is a better test?
productPackage: webBillingProduct.identifier: "yoga_1999_course_advanced"
matches
result.customerInfo.entitlements.active."Advanced Yoga Mastery".productIdentifier: "yoga_1999_course_advanced"
Could I iterate through result.customerInfo.entitlements.active checking for matching productIdentifier? If found, the test succeeds and we add user to course. If not, we show some error. That's important as the payment has been made!

CG said on above:

Exactly 👏 — you nailed the key idea.

The safest and most robust test is to match on productIdentifier, not on display names (titles, entitlement labels, etc.), because:

  • Titles / entitlement names are for humans → can change anytime.

  • productIdentifier (e.g. yoga_1999_course_advanced) is the stable ID that ties your product in Stripe/RevenueCat to the actual entitlement granted.


✅ Better test implementation

You can loop through the entitlements and check for a matching productIdentifier:

[Omitting suggested code as it was improved later and which is given below.]
...

🔍 Why this is better

  • Stable keys: productIdentifier (yoga_1999_course_advanced) comes from your product setup and won’t change if you rename the product or entitlement in RevenueCat.

  • Resilient to multiple entitlements: Works even if you later grant multiple entitlements for one purchase.

  • Debuggability: If there’s no match, you can clearly log it and handle gracefully (instead of silently failing).



After iteration(s), the suggested code became:

const activeEntitlements = result?.customerInfo.entitlements.active;

if (activeEntitlements) {
  const hasMatch = Object.values(activeEntitlements).some(
    (activeEntitlement: any) =>
      activeEntitlement.productIdentifier === productPackage.webBillingProduct.identifier
  );

  if (hasMatch) {
    await addUserToCourse(course.documentId.toString());
  } else {
    console.error(
      "Purchase succeeded but no matching entitlement found for product:",
      productPackage.webBillingProduct.identifier
    );
    toast.error("Something went wrong. Please contact support.");
  }
}
------------------
Before making above change, saved code with commit: Partially working web purchase: RC and Stripe part works but STRAPI User_course is not updated

Now am trying above code which is not complete. I had to modify it suitably.
Deleting customer in stripe and RC.
Purchased course again.
Now the relevant entry (Elevate Your Practice – Advanced Yoga Mastery) got added to User-course in STRAPI.
A minor issue is that when the course page got redisplayed after purchase, the button still showed Purchase course (or similar). Later, without refresh, it got changed to 'Continue Course'.
Going back to Home and coming back to this course shows 'Continue Course'.
RC shows the purchase (5 minutes ago).
Stripe shows the transaction (similar time but GMT).
So at least this purchase test has gone through completely - RC, Stripe and STRAPI all updated as expected.
So far, so good. As it is a tutorial project, I am not testing whether all the error conditions are handled correctly.

Commit: Web purchase works - RC, Stripe and STRAPI all updated as expected

Code cleanup done - mainly old unwanted comments deletion.

About specifying clerkId in RevenueCat Purchases: I had mentioned earlier in these notes that SG video has few lines of code related to this in RevenueCatProvider.tsx but the SG GitHub repo doesn't. See https://github.com/Galaxies-dev/lms-react-native/blob/main/providers/RevenueCatProvider.tsx

But RevenueCatProvider.web.tsx in SG GitHub repo has code to specify clerkId in RevenueCat Purchases. See https://github.com/Galaxies-dev/lms-react-native/blob/main/providers/RevenueCatProvider.web.tsx . It is slightly different in that it uses:
      Purchases.configure(APIKeys.web, clerkUser!.id);
which, BTW, is flagged as deprecated in VSCode. This is in line with RC doc: https://www.revenuecat.com/docs/getting-started/configuring-sdk#initialization for Web (JS/TS).

In contrast, RevenueCatProvider.tsx uses:
      if (Platform.OS === 'android') {
        await Purchases.configure({ apiKey: APIKeys.google });
      } else if (Platform.OS === 'ios') {
        await Purchases.configure({ apiKey: APIKeys.apple });
      }
---
Specifically in React Native example, appUserId (clerkUser.id in our app) is not used.

Instead SG adds a specific line in the video in RevenueCatProvider.tsx:
await Purchases.logIn(user!.id);
----

If you have your own App User IDs at app launch, you can pass those on instantiation to Purchases. Make sure to not hard-code this identifier, if you do all users will be considered the same one and will share purchases.
and gives following code for React Native:
Purchases.configure({apiKey: <public_sdk_key>, appUserID: <my_app_user_id>});
---
Then it goes on to say: Often times, you may not have your own App User IDs until later in the application lifecycle. In these cases, you can pass the App User ID later through the .logIn() method.

The following section, 'Logging in after configuration' gives this code example for React Native:
// Configure Purchases on app launch
Purchases.configure({apiKey: <public_sdk_key>});

//...

// Later log in provided user Id
const { customerInfo, created } = await Purchases.logIn(<my_app_user_id>);
// customerInfo updated for my_app_user_id
-----------

As of now, since I cannot test the Android code anyway, I will continue with RevenueCatProvider.tsx not associating clerkUser.id with Purchases (as in case of SG GitHub repo code). But later if I am in position to test Android code, I could consider adding the first code example given in RC docs:
Purchases.configure({apiKey: <public_sdk_key>, appUserID: <my_app_user_id>});
---

This will be similar to how it is done in RevenueCatProvider.web.tsx.

It is strange why VSCode is flagging the related line in RevenueCatProvider.web.tsx as deprecated when that is what current RC docs say. But it works as of now, as App User Id for Customer in RC is the clerk id (for my single purchase).
----

Testing after code cleanup. Deleted customer from RC and Stripe. Also deleted Advanced Yoga Mastery entry from User-course in STRAPI.
Testing Android app on phone. It works as expected (showing messages that purchase is not available on android).
Testing web app. Purchased course. RevenueCat, Stripe and STRAPI got updated as expected. App is now showing Continue for the purchased course.

Commit: Code cleanup with successful testing
================================

2nd October 2025
Me to ChatGPT (CG) in chat titled, "RC with Amazon Appstore":
I recently saw that RevenueCat seems to have some support for Amazon app store. If so, can I publish my Android app to Amazon app store for free and then use RevenueCat (RC) with it?

[That resulted in a short chat exchange ...]

Me: Can you prepare a short note summarizing this whole chat? I will put it up on my blog.

CG: Here’s a compact summary you can use for your blog (in your own voice if you like):

RevenueCat: Amazon Appstore vs Google Play

While exploring RevenueCat (RC) support for Android in-app purchases, I checked whether Amazon Appstore would be a good alternative to Google Play for learning purposes.

  • Amazon Appstore Support in RC:
    RC does support Amazon Appstore, but only with extra setup. You need the Amazon-specific RC library, credentials (shared secret / public key), and “Live App Testing” (LAT). RC does not validate receipts from Amazon’s older “App Tester” sandbox.

  • Amazon Appstore Developer Account:
    Free to register and publish apps (unlike Google Play’s $25 one-time fee). Amazon takes a store commission on IAPs.

  • Target Audience:
    Amazon Appstore is mainly used on Amazon devices (Fire tablets, Fire TV) and now Windows 11. While it can be installed on any Android device, it is not widely adopted as a general Google Play alternative.

  • Comparison with Uptodown:
    Uptodown positions itself as a Play alternative and is popular for APK distribution, but it lacks billing integration. RC only supports Google, Apple, Amazon, and Stripe for payments.

  • Learning Perspective:
    For someone just learning RC and IAPs, the extra effort for Amazon (special SDK setup, LAT testing) does not provide much benefit, since most Android users rely on Google Play.

Conclusion: It’s best to focus on Google Play + RC for learning, as it has cleaner integration, straightforward sandbox testing, and much wider real-world relevance. Amazon Appstore may be useful later if specifically targeting Fire/Windows 11 users, but not worth the time for a first learning project.



Me: For Android apps, RC supports only Google Play and Amazon app store. Am I right?

CG: Yes, you’re exactly right ✅

For Android apps, RevenueCat supports only these two billing providers:

  • Google Play Billing

  • Amazon Appstore Billing

That’s it.

👉 No direct support for other Android app stores like Samsung Galaxy Store, Huawei AppGallery, Uptodown, etc. If you publish to those, you’d have to handle billing yourself (outside RC).

So for Android + RC learning and production, it really comes down to Google Play (mainstream) and optionally Amazon Appstore (niche, Fire/Windows).

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

18:51 02 October 2025

Starting next lesson/stage of Showing Courses and Drawer Navigation : https://youtu.be/fO3D8lNs10c?t=12357 

npm i @react-navigation/drawer  Ref: https://youtu.be/fO3D8lNs10c?t=13069

Did ...
lms> npm i @react-navigation/drawer

added 3 packages, changed 6 packages, and audited 1240 packages in 13s

109 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
lms> 
---
Next uncommented drawer related code (lots of it) in app\(app)\(authenticated)\course\[slug]\[lesson]\_layout.tsx

Commit: Course pages (overview) show up for both beginner and advanced courses in web but dark mode has issues

Course pages (overview) shows up for beginner course in Android (on phone). No issues for dark/light mode.
No code changes had to be made for above.

Commit: Finished step/stage: Showing Courses and Drawer Navigation; but with web dark mode issue

Used color scheme approach of app\(app)\(authenticated)\course\[slug]\index.web.tsx in app\(app)\(authenticated)\course\[slug]\[lesson]\overview.tsx
That fixed the dark mode issue for web. As overview.tsx seems to be shared for both mobile and web, confirmed that dark/light mode works for Android. Note that reload of app ('r' on Metro) is needed for changes to be reflected correctly in Android app (and, at least sometimes, refresh page on web).
Commit: Fixed dark/light mode for web issue in Course lesson overview page; Fully finished stage: Showing Courses and Drawer Navigation
----------------

Starting step/stage: Building out the Custom Drawer - https://youtu.be/fO3D8lNs10c?t=13322

Back to My Content in Drawer bottom on Android and web in dark mode is not clearly visible. It is visible in light mode (on android and web).
SG is using the app in light mode in the video (and so the issue does not show up there).
web app has dark mode issue for lesson.
Also noted that web app menu bar has dark mode issue.

Commit: Fixed Drawer Lessons and Back labels dark mode issue for Android and web

Commit: Fixed web app's dark mode issue for lesson and for web app menu bar
======================================

Now doing step/stage: Creating Lessons with Expo Video - https://youtu.be/fO3D8lNs10c?t=14189

npx expo install expo-video
npm i react-native-fast-confetti @shopify/react-native-skia
Ref: https://youtu.be/fO3D8lNs10c?t=14207

Did the following ...
lms> npx expo install expo-video
env: load .env
env: export EXPO_PUBLIC_STRAPI_API_URL EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY EXPO_PUBLIC_REVENUECAT_WEB_KEY STRAPI_API_ADMIN_TOKEN
› Installing 1 SDK 52.0.0 compatible native module using npm
> npm install

added 1 package, and audited 1241 packages in 7s

109 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
› Added config plugin: expo-video
lms> 
....

lms> npm i react-native-fast-confetti @shopify/react-native-skia
npm error code ERESOLVE
npm error ERESOLVE unable to resolve dependency tree
npm error
npm error While resolving: lms@1.0.0
npm error Found: react@18.3.1
npm error node_modules/react
npm error   react@"18.3.1" from the root project
npm error   peer react@"*" from react-native-fast-confetti@1.1.1
npm error   node_modules/react-native-fast-confetti
npm error     react-native-fast-confetti@"*" from the root project
npm error
npm error Could not resolve dependency:
npm error peer react@">=19.0" from @shopify/react-native-skia@2.2.21
npm error node_modules/@shopify/react-native-skia
npm error   @shopify/react-native-skia@"*" from the root project
npm error   peer @shopify/react-native-skia@"*" from react-native-fast-confetti@1.1.1
npm error   node_modules/react-native-fast-confetti
npm error     react-native-fast-confetti@"*" from the root project
npm error
npm error Fix the upstream dependency conflict, or retry
npm error this command with --force or --legacy-peer-deps
npm error to accept an incorrect (and potentially broken) dependency resolution.
npm error
...
---------------
To fix above error, I plan to use SG repo package versions.
https://github.com/Galaxies-dev/lms-react-native/blob/main/package.json has:
    "react-native-fast-confetti": "^0.8.2",
    "@shopify/react-native-skia": "^1.11.8",
...
Did ...
lms> npm i react-native-fast-confetti@0.8.2 @shopify/react-native-skia@1.11.8

added 6 packages, and audited 1247 packages in 3m

109 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
lms> 
Above command took quite some time (1 or 2 minutes perhaps) on my 4G net connection which was giving around 10 Mbps download speed. 

CG said: That makes sense — npm installs for packages like @shopify/react-native-skia can take a minute or two, even on a decent connection.

A few reasons why it can feel slow:

  1. Native modules compilation

    • Skia has native C++ code that may need to compile or download prebuilt binaries.

    • Even if it uses prebuilt binaries, npm checks dependencies and extracts them, which takes time.

  2. Multiple dependencies

    • These packages bring in several sub-dependencies (e.g., react-native-reanimated, @shopify/skia-*), so npm downloads and links all of them.

  3. Network speed + npm registry latency

    • At ~10 Mbps, a few MBs of packages can take ~1–2 minutes, especially if there are many small files (extraction overhead).

  4. Package lock & audit

    • npm also updates package-lock.json and runs an audit automatically, which adds a few seconds.

✅ In short, this duration is normal for these types of native-heavy packages on a 4G connection.

--------------------

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

https://youtu.be/fO3D8lNs10c?t=14833 -> SG refers to expo-video documentation and then changes app.json.

https://docs.expo.dev/versions/latest/sdk/video/#example-appjson-with-config-plugin :
{
  "expo": {
    "plugins": [
      [
        "expo-video",
        {
          "supportsBackgroundPlayback": true,
          "supportsPictureInPicture": true
        }
      ]
    ],
  }
}
---------------------

Followed SG video and above ... Changed app.json from:
      ],
      "expo-video"
    ],
----
to:
      ],
      [
        "expo-video",
        {
          "supportsBackgroundPlayback": true,
          "supportsPictureInPicture": true
        }
      ]
    ],
----------

Above doc page: https://docs.expo.dev/versions/latest/sdk/video/ also gives Usage examples (as shown in SG video).

On web, this step/stage seems to work. I need to check whether data is updated correctly in STRAPI but on app frontend it seems to work OK. This includes playing video.

On Android, I get this error:
 (NOBRIDGE) ERROR  Error: Cannot find native module 'ExpoVideo' [Component Stack]
 
Commit: Stage Creating Lessons with Expo Video seems to work on web but fails on Android

I think I need to do an Android dev build and replace the old dev apk with the freshly built one on phone device (and emulator). 

10:50 03 October 2025
I am building locally and so will do:
npx expo prebuild --clean
npx expo run:android
...
Did ...
lms> npx expo prebuild --clean
env: load .env
env: export EXPO_PUBLIC_STRAPI_API_URL EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY EXPO_PUBLIC_REVENUECAT_WEB_KEY STRAPI_API_ADMIN_TOKEN
√ Cleared android code
√ Created native directory
√ Updated package.json | no changes
√ Finished prebuild
lms> 

Then I ran: npx expo run:android
BUILD SUCCESSFUL in 13m 57s
762 actionable tasks: 389 executed, 358 from cache, 15 up-to-date
...
› Installing C:\Users\{username}\NIBSProj\SG-EduApp\lms\android\app\build\outputs\apk\debug\app-debug.apk
Error: adb: failed to install C:\Users\{username}\NIBSProj\SG-EduApp\lms\android\app\build\outputs\apk\debug\app-debug.apk: Failure [INSTALL_FAILED_INSUFFICIENT_STORAGE: Failed to override installation location]
---------

Deleted some files from emulator. 
Ran: npx expo run:android
BUILD SUCCESSFUL in 24s
762 actionable tasks: 63 executed, 699 up-to-date
...
Above install error message was not shown and app opened on emulator

I was able to operate app on emulator for earlier features including course overview. But on opening lesson, it showed following errors:
 (NOBRIDGE) ERROR  TypeError: onHandleCompleteLesson is not a function (it is undefined)
...
 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.
...
---------------

But the video player was shown and I was able to play it. The notes were not shown below the video.
Also, on clicking 'Complete & Next lesson' I was shown the next lesson (Breath & Awareness). This time the notes were also shown (and no error was shown).
Is the above a one-time startup kind of bug?

After discussion with CG, understood cause of above bug (two issues).

CG: You’re seeing two separate but related issues:

1. onHandleCompleteLesson is not a function (it is undefined)

  • In your code, you have:

useEventListener(player, 'playToEnd', () => { onHandleCompleteLesson(); });

But at this point in the execution order, onHandleCompleteLesson is not yet defined (because it’s declared later in the component).

In React function components, function declarations defined with const are not hoisted — so when the event listener is registered, it sees onHandleCompleteLesson as undefined.

👉 That explains the (NOBRIDGE) ERROR.


2. "Can't perform a React state update on a component that hasn't mounted yet"

  • This warning comes from your side-effect inside render:

player.replace(lesson.video);

That line runs on every render, before the component is guaranteed to be mounted.
So React complains because the player may still be attaching when you call replace.

👉 This is why sometimes notes don’t show, or the UI glitches.

...

Me: ... The tutorial video shows the author running the code (which I think is in same order as in code I shared with you). He runs it on iOS emulator. He does not get these problems. Any idea why it worked for him?

CG: Let’s break down why the tutorial author didn’t see the problem, but you did:


1. JS Execution & Function Hoisting Differences

  • In your code:

useEventListener(player, 'playToEnd', () => { onHandleCompleteLesson(); });
  • onHandleCompleteLesson is defined after this line.

  • In plain JavaScript function declarations, the function would be hoisted (so it works even if defined later).

  • But here it’s a const arrow function, which is not hoisted.
    So at runtime, during the first render, it’s technically still undefined.

👉 Why it might have worked for him:

  • If the iOS simulator didn’t trigger the "playToEnd" event until after the component had fully mounted and onHandleCompleteLesson was already defined, then it would appear to work fine.

  • That’s probably what happened in his demo — he only tested by playing the video through, not triggering the event immediately.


2. Different Event Timing Between iOS & Android/Web

  • The Expo video player and event bridge behave differently across platforms.

  • On iOS, the "playToEnd" event fires strictly after playback finishes.

  • On Android/Web, sometimes events can fire earlier (e.g. when attaching listeners or when media metadata loads).

  • If the event fired before onHandleCompleteLesson was ready, you got undefined.


3. The player.replace side-effect

  • On iOS, the player may tolerate being replaced inside render without warnings.

  • On Android/Web, React is stricter and complains because you’re mutating state before mount.

That’s why the tutorial author never saw:

Warning: Can't perform a React state update on a component that hasn't mounted yet

Summary:

  • The tutorial code appeared to work because:

    1. iOS didn’t trigger the event until the handler was ready.

    2. iOS player was more forgiving about side-effects inside render.

  • On Android/Web, you hit stricter lifecycle checks and different event timing → so the same code shows real issues.


[CG suggested fix did not work  but after some iterations got a minimal fix that I wanted.]

Me: The following minimal fix code works for Android and web without showing run time errors (on main screen as against browser console). Some minor bugs are still there but the main issues I raised above seem to be fixed: 

[I shared code from related file...]

CG: Perfect! 🎉

This minimal fix is exactly what I would have suggested step by step:

What you did right:

  1. Guarded onHandleCompleteLesson

    • if (!lesson) return; ensures TypeScript is happy and prevents runtime errors on Android/web.

  2. Guarded player.replace(lesson.video)

    • Using if (lesson?.video) prevents trying to replace the video before it exists.

  3. Hooks are unconditional

    • useEventListener is called unconditionally, so React hook rules are respected.

  4. Maintains tutorial-style code

    • No useCallback or other big refactors needed, keeps your code close to the original tutorial.

  5. Cross-platform safe

    • Works on Android emulator and web. iOS should continue to work as well.

Minor remaining issues:

  • Confetti timing / overlap may still need tweaking (onEndCourse).

  • The player.replace runs on every render; using a useEffect with [lesson?.video] dependency could slightly optimize performance.

  • Some small layout or safe-area padding differences may appear on devices, but these are cosmetic.

Conclusion: Main functional issues are fixed. You can now proceed with other tutorial steps without runtime crashes related to lesson or player.

If you want, I can also suggest a tiny useEffect tweak for player.replace that is a bit cleaner and avoids calling it on every render. It’s completely optional.

Do you want me to show that?

Me: Also confetti animation (if that's the word), is shown on Android emulator of completing course.

I want to skip the optimization stuff and stay as close to tutorial code as I can. The focus is on learning React Native and how this app uses it. Optimization can be done later as I get into real-life project stuff.

------------

[Later update: The following seems to be an Android emulator on Windows 11 limitation. See later part of this post for notes from a chat with ChatGPT on it.] A small bug that I noticed is that choosing Back to my content, while on a lesson but not having 'completed' the lesson, seems to incorrectly mark the lesson as completed due to which when I continue the course, I am taken to the next lesson (with what was current lesson marked as completed).
...
Commit: Stage Creating Lessons with Expo Video main functionality works on web and Android; Minor bugs remain

Next step was to transfer dev build apk to phone and test.

I was able to transfer the dev build apk to my Android phone, install it and then run it there. It worked as expected including the confetti effect.

I have yet to fix the minor bugs mentioned above.
=========================

17:15 03 October 2025

On the video finishing for a lesson, the next lesson starts automatically. So the lesson index seems to be incremented.
One issue is that on the video finishing the last lesson, the lesson index gets incremented and the app seems to hang perhaps trying to get that non-existant lesson.
------------------

Me: As I was trying out the app on my mobile device, it worked for some lessons showing video correctly. But then it gave these errors:
 {"hydrate": undefined, "rootTag": "#root"}
Development-level warnings: ON.
Performance optimizations: OFF.
 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:66286: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:59540: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:50430: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:59540: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:50430:5)

[CG spotted that the problem is with RichtTextContent.tsx. After some exchanges ...]

Me: The following lines are repeatedly shown, and I am ignoring them:
 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.

CG: Yes 👍 — those lines are just standard Expo / React Native dev build logs.

They appear because:

  • Expo’s DOM runtime is printing setup info (hydrate, rootTag, etc.).

  • React Native dev mode logs its settings (warnings ON, optimizations OFF).

  • The [webview] Logs will also appear… line is from Expo’s DOM shim.

They’re not errors, only environment info, and they’ll keep printing whenever the app reloads.

👉 You can safely ignore them, or if they’re noisy you can filter them in Metro/console, but they won’t appear in a production build (release APK/AAB).

...

Me: In the following, the words 'above error' is misleading then. Isn't it?

 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.

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

...

CG: You’re right 👍 — the phrase “The above error” in that log is a bit misleading in this context.

...

So the wording is a bit confusing because it looks like it’s referring to the last LOG, but really it’s React’s generic phrasing for “this error we just caught happened while rendering this component”.

Me: Got it. That helps a good deal as I was quite confused with those words. ... Now about digging in deeper to catch this occassional bug. The lesson code (index.tsx) checks for undefined lesson and so lesson surely is defined before the RichtTextContent component code is reached. 

      <View className="flex-1 p-4 min-h-[100px]">

        <RichtTextContent 

          colorScheme={colorScheme}

          blockContent={lesson.notes} 

        />

      </View>

------

So only notes can be undefined. Is my analysis correct so far?

[CG agreed. After some more exchages, I added some guard code in lesson index.tsx to check notes before using it in rendering code. But there was some STRAPI related stuff which I have given below...]

Me: In content-type builder, for lesson, notes has TYPE Rich text (Blocks)

...

CG ... In Strapi, a Rich Text (Blocks) field is always returned as an array of blocks (even if empty). So the checks you wrote [suggested by CG] are perfect:

const hasNotes = lesson.notes && Array.isArray(lesson.notes) && lesson.notes.length > 0; if (!hasNotes) { console.log("Lesson notes are missing or empty for lesson:", lesson.documentId); }
  • lesson.notes exists? ✅

  • Is it an array? ✅

  • Does it have any blocks? ✅

If any of these fail, you can safely log a message or show a fallback UI like “No notes available for this lesson.”

...
Me: That RichtTextContent was the cause, was buried somewhat deeply in the React error message. So I did not catch it. Why is React not providing more friendly error messages?

CG: Ah, I get why that was confusing! React’s error messages are usually precise, but sometimes they’re too precise and low-level, which makes them hard to read for the actual cause. Here’s why this happens:
  1. Component stack is nested:

    • React will show the first component that threw the error (like <DOMComponentRoot> in your case) rather than the innermost custom component (RichtTextContent).

    • Any async effects or external libraries (like @strapi/blocks-react-renderer or Expo DOM) can wrap your component, so the “top of the stack” is not your own code.

  2. Bridging in React Native / Expo for web:

    • On web, Expo uses DOMComponentRoot and other internal wrappers to map React Native components to DOM.

    • Errors inside children often bubble up through these wrappers, so the error looks like it’s coming from the wrapper rather than your component.

  3. Errors in render vs. hooks:

    • If a render tries to use invalid data (lesson.notes undefined in a component expecting an array), React throws during render.

    • React only shows the stack from where it actually threw, which can hide the user-level component in the middle.

  4. Stack trace minification / bundling:

    • In development, it’s easier to read, but if you bundle or use Hermes (your Android JS engine) it can make the error messages more cryptic.

Bottom line:
React is telling you exactly where it crashed, but because of wrappers (Expo DOM, React Native bridges, libraries like Strapi blocks), the actual source of the problem gets “buried” in the stack. You often have to trace back from the stack and reason about which of your components could have produced invalid input.

Tips for friendlier debugging:

  • Use console.log early in render to see what props are being passed.

  • Wrap components that depend on external data in guards (if (!data) return <Loading />)—like you did.

  • Use TypeScript types carefully—TypeScript will catch some of these cases before runtime.

...

Me: Well, putting more burden on developers by having to code for such stuff is not developer friendly ... Somehow I feel React could have done a better job in reporting this error.

...

[I faced a similar invalid lesson related error on phone too. I got a Log Error message for that condition, which the Call Stack said is from getLessonForCourse from StrapiProvider.tsx. That matches the catch block code in that function. That implies that fetch returns response.ok. ...  I discussed with CG about the backend API returning OK even if it gets no data for a particular lesson. ...]

Me: ... The backend API for lesson services has:

/**

 * lesson service

 */

import { factories } from '@strapi/strapi';

export default factories.createCoreService('api::lesson.lesson');

------

It is similar for lesson controllers and routes


CG: Yes, that makes sense. What you’re seeing is the default Strapi core service behavior:

  • The service createCoreService('api::lesson.lesson') provides the standard find, findOne, create, etc.

  • It doesn’t throw an error if a find query returns no records; it just returns an empty array.

  • That’s why your fetch call succeeds (response.ok === true), but result.data[0] is undefined if the lesson for that lessonIndex doesn’t exist.

So the runtime TypeError isn’t a network or HTTP error — it’s simply because the client code assumes result.data[0] exists without checking.

To handle this robustly, your client code must explicitly check for missing lesson, as I suggested:

if (!result.data[0]) { throw new Error(`No lesson found for slug "${slug}" and lessonIndex ${lessonIndex}`); }

Or return null and let the React component render a “Lesson not found” message.

Strapi itself isn’t “broken” here — it’s behaving as designed with its core service. The fix has to be in client-side handling of empty responses. ...


Me: Excellent explanation! The tutorial is focused on teaching React Native and so has taken shortcuts and skipped good error handling - that is acceptable I think. But I still wanted to know why the backend API was not returning a failure. Your explantion makes the picture clear. Used your above code suggestion.
...

Me: ... I still need to prevent top-level code from calling this getLesson function with wrong lesson index as that will throw an error but the error message will be clearer.

[CG explained how to do that with tanstack queries using enabled option. After some discussion including about how to handle loading case, I got the code I wanted. On testing, it seemed to handle the error case as expected by showing the message to the user, except for a dark mode issue which we fixed.]

--------------------------

Android emulator seems to have issues with the lesson page with video. The screen is black at times, at times there is a flicker. On phone, I don't face such issues. I wonder whether it is an Android emulator and Expo-Video issue.

Commit: Handled condition of invalid lesson index with message on screen; ToDo Prevent invalid lesson case from happening
--------------------------

Commit: Fixed invalid lesson bug on video of last lesson of course finishing

Commit: Made timeout 0 for web course complete as we don't show confetti on web

About what I had mentioned earlier: A small bug that I noticed is that choosing Back to my content, while on a lesson but not having 'completed' the lesson, seems to incorrectly mark the lesson as completed due to which when I continue the course, I am taken to the next lesson (with what was current lesson marked as completed).
----
This issue is not happening on web nor on phone device. But it happens on Android emulator.

I had a chat with ChatGPT on it. ...
Me: In a React Native project, I find that Android emulator on Win 11 seems to trigger some expo-video event perhaps which is unwarranted whereas on Android device I don't face that issue. Is expo-video well supported on Android emulator on Windows 11?
...
Me: I suspect that playToEnd event is firing spuriously on Android emulator on Win 11 ...
  useEventListener(player, 'playToEnd', () => {
    onHandleCompleteLesson();
  });

CG: Ah, yes — that aligns with what we know about expo-video / react-native-video on Android emulators. The playToEnd event (or onEnd in expo-av) can fire spuriously on emulators due to how video buffering and software decoding are handled. On real devices, it’s usually fine.
...
[CG suggested some workarounds in code...]

Me: I think I will avoid trying to handle an emulator issue in my code as I am learning RN through this tutorial project. Instead I request you to provide me a note on this spurious event firing issue for Android emulator on Windows 11 which I will add to my blog.

CG: Absolutely! Here's a concise and clear note you can add to your blog regarding this issue:

Note on expo-video / Android Emulator Behavior on Windows 11

While working with Expo’s Video component (expo-video / expo-av) in React Native, you might notice that the playToEnd (or onEnd) event sometimes fires unexpectedly on Android emulators running on Windows 11.

Key points:

  • This issue is emulator-specific; it does not occur on real Android devices.

  • The Android emulator often uses software video decoding and may handle video buffering differently from a physical device.

  • As a result, events like playToEnd or onPlaybackStatusUpdate can trigger spuriously, sometimes immediately on component mount or before the video has actually finished.

  • This behavior is not a bug in your app code, but a known limitation of the emulator environment.

  • For tutorial projects or learning purposes, it’s usually best to ignore this emulator quirk and rely on real devices to verify video playback behavior.

  • If needed in production apps, you can use state flags or Device.isDevice checks to prevent unintended handling of these events on emulators.

Takeaway:
The Android emulator on Windows 11 is useful for general UI testing, but video playback events may not exactly mirror real device behavior. Always validate video-related logic on a real device before deploying.


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

12:24, 07 October 2025

On Android app on phone, if I am on a particular lesson - e.g. Relaxation & Meditation and either I have not played the video or stopped it before completion, then there seems to be a bug in the following operation:
a) From the drawer menu, go back to my content.
b) Tap on the course in my content to continue the course.
c) For a few seconds, the Relaxation & Meditation lesson page title alone is shown (video and other parts of page are not shown).
d) Then, automatically, the next lesson page Final Flow & Graduation is shown with the video at starting point (not playing automatically).

So the bug is that on Android app on phone, continuing the course briefly shows the current lesson page title only and then automatically jumps to the next lesson.

The web app does not have this bug. It continues the course from the lesson it was on previously and does not automatically jump to the next lesson.

In my note of 3 Oct, I had referred to this bug but said that "This issue is not happening on web nor on phone device. But it happens on Android emulator." 

Today I am seeing it happen on phone device (dev build)! Now I am wondering whether I had tested the app on phone properly earlier (for abovementioned entry on 3 Oct.).

I am now recording details of the bug on Android app on phone.
1) I am on 'Seated & Hip Openers' lesson of Beginner's Yoga course.
2) For this user, in User-course collection in STRAPI, next_lesson_index is 5.
3) I tap 'Complete & Next Lesson' in app. The app shows 'Core Strength & Stability' lesson page
4) For this user, in User-course collection in STRAPI, next_lesson_index is now 6.
5) I use the Drawer menu's 'Back to my Content' link to go back to My Courses.
6) For this user, in User-course collection in STRAPI, next_lesson_index is still 6. I refreshed the page once again and confirmed it is still 6.
7) I tap on the course (Beginner's Yoga ...) in My Courses page. It briefly shows me 'Core Strength & Stability' lesson page title alone (rest of page/screen is black). Then, automatically, 'Backbends & Heart Openers' lesson page is shown.
8) For this user, in User-course collection in STRAPI, next_lesson_index is now 7.

The above observation shows that opening the lesson page on the Android app, seems to trigger something that results in next_lesson_index being updated.
Probably the onHandleCompleteLesson() method is being invoked when the lesson page is opened. To confirm this, I will add console log statements in onHandleCompleteLesson().

The console.log statements confirm that opening the lesson page on the Android app invokes onHandleCompleteLesson() which then invokes markLessonAsCompleted, bumping the next_lesson_index to 8 (from 7).

Added console log statement in playToEnd() event handler to check if playToEnd() is getting spuriously called on opening lesson page in Android app.
The console log statement confirmed that playToEnd() event handler is invoked spuriously on opening lesson page in Android app which invokes onHandleCompleteLesson() and thus the next lesson page gets shown (with next_lesson_index in STRAPI being bumped to 9).

Then ran web app which is for another user and so STRAPI collection row is different. The above console log statements do not show on entering the lesson and so there is no spurious playToEnd() event and the correct lesson page is shown. I also checked that the web app handles a normal playToEnd() event correctly, at which time the above console log statements show (in browser console), the STRAPI row next_lesson_index is bumped up by 1 and the next lesson page is shown, as expected.

Is it possible that this spurious playToEnd() event happens only for dev build Android app (when run on phone)?

[Shared above log note with CG...]
CG: That’s a very thorough and clear bug log — excellent diagnostic notes! 👏

You’ve essentially discovered that:

  • The spurious playToEnd event—which was originally observed only on the Android emulator

  • is also firing on a dev build running on a real Android device,

  • and that this causes your onHandleCompleteLesson() logic to run automatically, advancing the course unexpectedly.

...

Me: What do u think? Is it possible that this is a dev build issue?

CG: That’s an excellent and very plausible hypothesis, Ravi — and based on your detailed observations, I’d say:

➡️ Yes, it’s quite possible (even likely) that this is a dev build–only issue.

Let me explain why in detail — step by step.


1. Dev builds behave differently from release builds

When you run a development build (via expo start --dev-client or Metro bundler), the app runs with:

  • Hot reload / Fast Refresh

  • Debug JavaScript mode

  • Extra logging and instrumentation

  • Development version of the expo-video native module, with more frequent state syncs

These add overhead and can cause timing glitches, especially in media components like video players that depend on precise native lifecycle handling.

So, even though your playToEnd handler fires as soon as the screen mounts, that might not happen in a release (production) build — where:

  • Dev tooling is removed

  • Frame timing is deterministic

  • No re-render interruptions occur during mount


2. Why video events can misfire in dev mode

The Expo video component (expo-video, or previously expo-av) triggers events like playToEnd through native bridge messages. In development:

  • React Native reloads JS bundles often and reattaches event listeners.

  • The video’s internal state (e.g., playback position = 0, duration known) can lead the JS side to think it has “ended” before playback starts.

  • These phantom events are more common when the player mounts and unmounts rapidly — exactly what happens when you navigate lessons and return via drawer menus.

That’s why:

  • On web, which uses browser-native playback and not RN bridge events → no issue.

  • On emulator and dev build on phone, where native bridge is active and Metro debugger is attached → spurious event.

  • On release build, those extra transitions and reattachments usually don’t occur → event won’t misfire.

...
[CG said disabling Fast Refresh temporarily may fix the issue but I did not want to invest time on that. Neither did I want to invest time in doing a production build now.]

[I asked CG to check web to see if this problem is reported by others. CG could not get an exact match though there were quite a few problems mentioned about events for expo-video / expo-av.]

Me: Google search AI says:
The onPlaybackStatusUpdate event, which contains the didJustFinish property indicating the playToEnd() event, can sometimes fire spuriously on screen mount in a React Native Expo Android development build, even when the video has not actually finished playing. This behavior is not consistently observed on the web.
Potential Causes and Solutions:
Race Conditions or Initial State:
The video component might be initialized in a state where didJustFinish is briefly true before the video actually starts loading or playing.
Solution: Introduce a state variable (e.g., isMounted) to track when the component has fully mounted and the video is ready to be interacted with. Only process onPlaybackStatusUpdate events after isMounted is true.
=====================

Above description seems to be for expo-av something but I was wondering whether we could also add a check for whether component is mounted in our playToEnd() event handler before invoking its other code. What do u say?

CG: Excellent catch, Ravi 👏 — yes, that Google AI explanation is right on target for expo-av (and by extension expo-video, since it wraps similar native playback logic).

And your idea is 100% sound: adding a “component mounted and video ready” guard before responding to the playToEnd() event is both safe and elegant. It’s a preventive filter that ignores early, spurious events fired during initialization or race conditions — especially common in dev builds.

[CG suggested two code options. I took the simpler one, given below ...]

const [isMounted, setIsMounted] = useState(false);

useEffect(() => {
  const timer = setTimeout(() => setIsMounted(true), 500); // short grace period
  return () => clearTimeout(timer);
}, []);

useEventListener(player, 'playToEnd', () => {
  if (!isMounted) {
    console.log('Ignoring spurious playToEnd event during mount');
    return;
  }
  onHandleCompleteLesson();
});
--------------

Me: With code fix ...
On Android app on phone,
I am on lesson 'Backbends & Heart Openers'.
I go back to my content. I tap on course in My Courses page.
I am shown same lesson 'Backbends & Heart Openers'.
Metro console shows:
 (NOBRIDGE) LOG  Ignoring spurious playToEnd event during mount
----
I play the video on current lesson 'Backbends & Heart Openers' till end of video.
I am shown the next lesson 'Twists & Detox Flow'
Metro console shows:
 (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": "oofa3q2l4vdo0zbk8lvt35uh", "nextLessonIndex": 8, "progress": 70}
DOM Bundled 10ms 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.
----

That I think fixes the issue. I need to do more testing including web app testing (and perhaps Android emulator testing). But the core problem seems to have been isolated and the workaround works for Android app.

CG: Excellent — that’s a clean and conclusive test result. ✅

Your logs clearly confirm both:

  1. The spurious playToEnd event firing during mount (and now being gracefully ignored), and

  2. The real event firing correctly at the end of actual playback — invoking onHandleCompleteLesson as expected.

That confirms the isMounted filter works exactly as intended.

So yes — the core problem (a spurious playToEnd event during component mount in Android dev builds, especially in emulator) is now isolated and neutralized.

[CG provided a short summary note on this issue ...]

Observed that the playToEnd event in expo-video can fire spuriously when a lesson screen mounts in the Android dev build (on a real device as well as emulator on Windows 11), even though the video hasn’t finished playing. This does not occur in the web app and may be a dev build–only issue. Added a simple guard using an isMounted state flag with a short delay before handling playToEnd events. This prevents false triggers while preserving normal behavior. Verified that the workaround fixes the issue on Android phone.

-----

Me: Two minor quirks. 1) Console logs were not showing on Metro. I had to restart it and then they showed. Does that happen or did I mess up somewhere?

2) Initially I skipped the delay in playToEnd(). That did not work ... Seems like delay is necessary. pls explain why.

CG: Ah, both of these are actually normal quirks of React Native / Expo development — nothing you “messed up.” Let me explain each carefully.


1️⃣ Console logs not showing until Metro restart

  • Why it happens:
    In a React Native dev build (Expo Go or custom dev client), the Metro bundler maintains a live JS connection to your device. Sometimes, after:

    • Hot reloading,

    • Fast refresh, or

    • Navigation between screens,

    the console log stream can get “stuck” or fail to attach properly to the device. Metro will still run, but logs don’t appear until you restart the bundler or reload the app.

  • Key points:

    • This is common on Android, less common on iOS.

    • It’s not a bug in your code — the logs themselves were being called, they just weren’t streaming to Metro.

    • Restarting Metro or reloading the app ensures a fresh connection for the log stream.

💡 Tip: For consistent logging, after adding new console.log statements that aren’t appearing, do a full reload (R or “Reload JS”) or restart Metro.


2️⃣ Why the delay in playToEnd() is necessary

  • Initially you tried guarding playToEnd() with isMounted without a delay, and it didn’t work. Here’s why:

How it works internally:

  1. Video component mounts: The native player initializes asynchronously.

  2. React Native dev build: Fast Refresh / bridge may immediately trigger playback status updates — including didJustFinish: truebefore JS side has fully mounted.

  3. Without delay: isMounted flips to true immediately, while the native player is still “settling”, so the first spurious playToEnd still fires.

  4. With a short delay (e.g., 500ms): You give the player time to:

    • Finish initialization,

    • Load the video metadata,

    • Set a proper playback position,

    Before isMounted allows the event to be handled. The first early playToEnd event is ignored, but the real end event (after the user actually plays the video) is handled normally.

  • Summary: The delay is essentially a grace period to let the video component stabilize, avoiding race conditions between JS mounting and native video status initialization.


✅ So both quirks are expected:

  1. Metro logs sometimes need a restart — normal in dev builds.

  2. The short delay in isMounted is necessary to filter the early, spurious playToEnd event.


...

Me: Hmm. So my RN component (Page) finishes its mount stuff. But the video component is still initializing and so we have the spurious event. The key thing is to wait till the video component finishes initializing. The delay does that. did i understand correctly?

CG: Exactly — you’ve got it perfectly. ✅

  • Your React Native Page component finishes mounting (useEffect runs, state updates, etc.).

  • But the <Video> component (expo-video / expo-av) is still initializing its native player asynchronously.

  • During that brief initialization window, the native layer can report didJustFinish: true to JS even though the video hasn’t actually played — that’s the spurious playToEnd event.

  • The short delay in isMounted acts as a grace period, giving the video component time to complete its initialization. Only after this window do you start processing playToEnd events.

So yes: waiting ensures the video is “ready” before handling the end event, which prevents the false trigger without affecting normal playback behavior.

It’s a classic race condition between component mount and native video initialization — the delay is a simple and effective way to fix it.

If you want, I can also explain a slightly more “robust” alternative that doesn’t rely on an arbitrary delay, using the video’s loaded/ready status. Would you like me to?

Me: That was the next question I had. If the video component provides a loaded/ready event, we can use that instead of a random timeout.

...

[CG had given me a version similar to this 'better' version earlier on itself but at that time, I was looking for a simple fix. ...]

Me: I only want to solve the spurious event problem, so if OnLoad ensures that fix, OnLoad is good for me. But I am using expo-video. Does it have a Load event?

CG: Yes — in Expo Video (expo-video), there is an onLoad event (sometimes also called onLoadStart/onLoaded in docs) that fires when the video metadata has loaded (duration, dimensions, etc.). This is enough for your purpose because:

  • The spurious playToEnd event happens before the video is fully initialized.

  • Waiting for onLoad ensures the video component is ready.

  • Once onLoad fires, any subsequent playToEnd is legitimate.


Example for your case:

const [isVideoReady, setIsVideoReady] = useState(false); <Video ref={player} source={{ uri: videoUri }} onLoad={() => setIsVideoReady(true)} // fires when video is loaded onEnd={() => { if (!isVideoReady) { console.log('Ignoring spurious playToEnd before video is ready'); return; } onHandleCompleteLesson(); }} shouldPlay={false} />

Notes:

  • onLoad is called once per video load, so it’s perfect for guarding against the first spurious event.

  • You don’t need setTimeout or an arbitrary delay anymore.


✅ So yes, onLoad in expo-video is exactly what you need to reliably solve the spurious playToEnd problem.

---- end of chat extracts ----------

Before I try out the above, I saved the current code (with earlier fix) ...

Commit: Workaround fix for spurious PlayEnd event on Android app

------------

16:45, 07 October 2025

On closer examination of CG suggested code (which does not use useEventListener), I decided to read up the reference pages for expo-video to check for its onLoad or equivalent event.
Ref page sections for SDK 52 (which is what this project uses):
status - It does not list any method like isLoaded

Searching for example code did not give me what I wanted. 
It pointed to related part of expo repo

I think it may not be appropriate to invest time in figuring out the right code for checking loading status of expo-video component. I don't think CG is up to date on expo-video and so I don't want to spend time on trial and error.

As of now, I have a workaround with delay. I will go with that for this tutorial, as of now at least.
=======================

Made minor code changes including a short comment on above.
For above issue now both web and android app on phone work.

Commit: Minor changes to workaround including comment

On Android and web:
A minor bug is that choosing a lesson from the drawer takes one to the lesson but the app does not seem to update current lesson in database. So if we go back to my content and then back to the course, one is shown the earlier lesson (before lesson change through drawer).
The cause of above bug seems to be ...
In app\(app)\(authenticated)\course\[slug]\[lesson]\_layout.tsx
the onPress for DrawerItem is:
              onPress={() => router.push(`/course/${slug}/${lesson.lesson_index}`)}
-----
So user goes to another page but in app\(app)\(authenticated)\course\[slug]\[lesson]\index.tsx we getLesson for the course but do not update the backend by calling markLessonAsCompleted.

The solution for this is a little complicated and so I am postponing it to if & when needed. In CG's words (based on my explanation to CG):

Here’s the essence of your point:

  1. Current behavior of markLessonAsCompleted:

    • It assumes the lesson was actually completed by the user.

    • Updates “current lesson” and progress in the backend based on that assumption.

    • Works fine if the user actually finished the previous lesson.

  2. Problem when just navigating via drawer:

    • The user may jump to a lesson without completing the previous one.

    • Calling markLessonAsCompleted would incorrectly update progress and current lesson, because the backend doesn’t know the real completion state of prior lessons.

  3. Ideal solution:

    • Have a method like setCurrentLessonAndProgress that only updates the current lesson and progress in the UI/backend without marking it as “completed.”

    • This way, navigation via the drawer won’t corrupt progress data.

  4. Decision:

    • Since this is just tutorial code and you don’t want to modify backend now, it’s reasonable to postpone this fix.

    • For now, leaving the current behavior as-is is okay, because the “next lesson” logic still works correctly for genuinely completed lessons.


In short: yes, I understand — you’re aware of the subtle difference between “marking as completed” vs. “just updating current lesson/progress,” and you’re deliberately postponing the fix because changing backend now would be overkill. ✅

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

As I was reviewing the expo-video related segment of the tutorial, at https://youtu.be/fO3D8lNs10c?t=15422 I saw that StrapiProvider.tsx has an updateUserCourseProgress method already. So I then started exploring whether that could be used for the above issue.

Me: An update on the above. ...[I shared StrapiProvider.tsx code.]
...
An interesting temporary improvement is to call updateUserCourseProgress which is currently an internal function called by markLesson...
We could make updateUserCourseProgress an export function and call it from our index.tsx in the useEffect.
This will help us set the next_lesson_Index to current lesson and progress based on current lesson position in all lessons. The progress part is not very accurate but then even if we move to next lesson using the app UI, the progress is updated based simply on index of the previous lesson.
The advantage of this would be that if the user chooses a different lesson from the drawer menu, and then goes back to my content without completing that lesson, and then continues the course again, he would go to the same lesson he was in before he went back to my content. That would be a significant improvement in the UI. What do u say

[CG provided code ... some iterations ... One iteration did have async await for calls to updateUserCourseProgress and queryClient.invalidateQueries but I was focusing on simpler solutions on lines of existing code in the tutorial, and so CG dropped that (which was a mistake on my part and perhaps on CG's part as that created a race condition described later on).]

[The main code added to lesson/index.tsx is given below and which has the race condition:]

  const externalNavigationRef  = useRef(true);
...
  useEffect(() => {
    if (!lesson || !lessons) return;

    console.log("In useEffect for updating course progress. externalNavigationRef.current =", externalNavigationRef .current);
    // Only update when navigation is external (not from local router.push)
    if (!externalNavigationRef .current) {
      externalNavigationRef .current = true;
      return;
    }    
    const index = parseInt(lessonIndex);
    const progress = Math.floor(((index - 1)/ (lessons?.length || 0)) * 100);

    updateUserCourseProgress(lesson.course.documentId, progress, index, true);
    queryClient.invalidateQueries({ queryKey: ['userCourses'] });
  }, [lesson, lessons, lessonIndex, queryClient]);

  const onHandleCompleteLesson = () => {
    console.log("Entered onHandleCompleteLesson");
    if (!lesson) return; // <- guard for TypeScript
    const progress = Math.floor((parseInt(lessonIndex) / (lessons?.length || 0)) * 100);

    console.log("Invoking markLessonAsCompleted with:", {
      lessonId: lesson.documentId,
      courseId: lesson.course.documentId,
      progress,
      nextLessonIndex: parseInt(lessonIndex) + 1
    });
    markLessonAsCompleted(
      lesson.documentId,
      lesson.course.documentId,
      progress,
      parseInt(lessonIndex) + 1
    );

    queryClient.invalidateQueries({ queryKey: ['lessons', slug] });
    queryClient.invalidateQueries({ queryKey: ['userCourses'] });

    // If there isn't a next lesson, simply stay on same lesson. This is a bug fix.
    // If required, later a better UI can be considered.
    if (hasNextLesson) {
      // mark that the next navigation is internal
      externalNavigationRef .current = false;
      router.push(`/course/${slug}/${parseInt(lessonIndex) + 1}`);
    }
  };
------

[I think I had wanted to follow same style of coding as used in onHandleCompleteLesson() which does not use await for markLessonAsCompleted and queryClient.invalidateQueries. I presumed that this tutorial code would be OK and so I should follow similar style in the above useEffect that we added.]
...

Me: ... Now about the current issue. On using drawer menu to jump to lesson, I am shown this error in web app:
Uncaught Error
HTTP error! status: 404
Call Stack
updateUserCourseProgress
providers/StrapiProvider.tsx
Collapse 1 frame

CG :  ...

2️⃣ Why a 404 happens

  • courseId passed here must be the documentId of the “user_course” record, not the course itself.

  • markLessonAsCompleted internally calls updateUserCourseProgress with the correct user_course documentId, fetched via getUserCourses().

  • In your useEffect, you’re calling it with lesson.course.documentId, i.e., the course document, not the user-course document.

✅ Hence, the endpoint doesn’t exist → 404.

[CG suggested some solutions ... ]

Me: Another fix is to add an optional parameter like passedLessonCourseId to updateUserCourseProgress which is false by default. If that is true then updateUserCourseProgress could do the mapping from lesson course id to user-course course id by find through userCourses returned by getUserCourses.

... some iterations ....

Me: The web app is working as expected for both drawer navigation and lesson page navigation. Great! Thanks a ton! Will later check the Android app on phone for this navigation.

[Later I found that this functionality was not working on Android app on phone.]

Commit: Drawer menu lesson navigation updates lesson index for web; Android is not working

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

Debugging why this functionality was not working on Android app on phone. ...

As part of that debugging, I had to dig into STRAPI data. ...

Me: In STRAPI how do I see documentId for a collection row
...
Me: I am referring to documentId used in code below:
        const userCourses = await getUserCourses();
        const uc = userCourses.find((uc) => uc.course.documentId === courseId);
...
Me: For User-course STRAPI admin shows id field with values like 5, 15 and 11. But console.log gives:
Entered updateUserCourseProgress with: {"courseId": "caidhct21sy7lhabvqhauho9", "mapToUserCourse": true, "nextLessonIndex": 6, "progress": 50}
Note that these are input parameters to the method. I am trying to find "courseId": "caidhct21sy7lhabvqhauho9" in user-course or course collection but can't see any value like that.
...
Me: I can't see documentId in Edit View of Strapi admin.
...
[CG suggested using an API via browser like:
 http://localhost:1337/api/courses?populate=*&pagination[pageSize]=100
 That or similar API gave me a ton of data initial part of which was:]

Me ... {"data":[{"id":2,"documentId":"exq61js9lywj287gioo2k32q","title":"🔥 Elevate Your Practice – Advanced Yoga Mastery 🧘‍♂️" ......

From where did this documentId come? 

[CG was not able to provide the right answer. ... Then I asked it to search the web.]

CG: 

In Strapi, the documentId is a system-generated field introduced in version 5 to uniquely identify records across different content types. This identifier is especially important when working with the Document Service API, as it supersedes the traditional id field for referencing records.


🔍 Locating the documentId in the Admin Panel

While the documentId isn't explicitly shown in the Strapi Admin Panel by default, you can retrieve it through the API. Here's how:

  1. Access the Collection: Navigate to your collection (e.g., user-courses) in the Strapi Admin Panel.

  2. Use the API to Fetch Records: Make a GET request to the appropriate endpoint, such as:

    GET /api/user-courses?populate=course

    This request will return a list of records, each containing a documentId field.

  3. Inspect the Response: Look for the documentId in the response data. For example:

    { "data": [ { "id": 2, "documentId": "exq61js9lywj287gioo2k32q", "course": { /* course details */ } } ] }

🛠️ Understanding the documentId

In Strapi 5, the documentId is used to uniquely identify records when interacting with the Document Service API. This change aligns with the introduction of the Document Service API, which handles content operations at a more granular level. For instance, when updating a record, you should reference it by its documentId instead of the traditional id field. docs.strapi.io


✅ Recommended Approach

To ensure compatibility with Strapi's latest API standards, always use the documentId when performing operations like updates or deletes. For example, when updating a user's course progress, you would use:

updateUserCourseProgress(userCourse.documentId, progress, nextLessonIndex);

This approach ensures your application remains compatible with Strapi's evolving API structure.


[That gave a clearer picture of this documentId stuff. ... I later used some API urls in browser to get key data with documentId from STRAPI backend. The relation part required some variation which CG helped me to get. The data from those API urls is given below ...]

Output of http://localhost:1337/api/courses?fields[0]=documentId&fields[1]=title

{
  "data": [
    {
      "id": 2,
      "documentId": "exq61js9lywj287gioo2k32q",
      "title": "🔥 Elevate Your Practice – Advanced Yoga Mastery 🧘‍♂️"
    },
    {
      "id": 4,
      "documentId": "caidhct21sy7lhabvqhauho9",
      "title": "🧘‍♀️ Beginner’s Yoga Journey – Flow Into Balance 🌿"
    }
  ],
  "meta": {
    "pagination": {
      "page": 1,
      "pageSize": 25,
      "pageCount": 1,
      "total": 2
    }
  }
}


Output of http://localhost:1337/api/user-courses
{
  "data": [
    {
      "id": 96,
      "documentId": "mo06lybnrsehtdrd09a5k4r9",
      "clerkId": "user_2xBpZwuCMK7AUEKRFT4Q1hcm9uk",
      "finished_percentage": 40,
      "next_lesson_index": "3",
      "createdAt": "2025-10-01T15:05:19.834Z",
      "updatedAt": "2025-10-03T16:15:53.119Z",
      "publishedAt": "2025-10-03T16:15:53.132Z"
    },
    {
      "id": 208,
      "documentId": "mpzhotqasah33ub3ahkg1qsj",
      "clerkId": "user_2xM5iCisxx9uJQ3XjDJM83T0oLz",
      "finished_percentage": 50,
      "next_lesson_index": "6",
      "createdAt": "2025-08-23T13:34:55.987Z",
      "updatedAt": "2025-10-07T17:22:14.264Z",
      "publishedAt": "2025-10-07T17:22:14.268Z"
    },
    {
      "id": 211,
      "documentId": "yxvzzjc4o9gfru6vtswypzxe",
      "clerkId": "user_2xBpZwuCMK7AUEKRFT4Q1hcm9uk",
      "finished_percentage": 70,
      "next_lesson_index": "8",
      "createdAt": "2025-08-23T08:03:19.398Z",
      "updatedAt": "2025-10-07T17:26:37.720Z",
      "publishedAt": "2025-10-07T17:26:37.723Z"
    }
  ],
  "meta": {
    "pagination": {
      "page": 1,
      "pageSize": 25,
      "pageCount": 1,
      "total": 3
    }
  }
}

Output of http://localhost:1337/api/users
[emails are snipped]
[
  {
    "id": 1,
    "documentId": "e0kwjdbsfliqs873du6qd3k9",
    "username": ---snipped---,
    "email": ---snipped---,
    "provider": "local",
    "confirmed": true,
    "blocked": false,
    "clerkId": "user_2xBpZwuCMK7AUEKRFT4Q1hcm9uk",
    "createdAt": "2025-05-16T18:49:39.756Z",
    "updatedAt": "2025-05-16T18:49:39.756Z",
    "publishedAt": "2025-05-16T18:49:39.757Z"
  },
  {
    "id": 2,
    "documentId": "f5emodyt1xh5yxni0xr11t7r",
    "username": ---snipped---,
    "email": ---snipped---,
    "provider": "local",
    "confirmed": true,
    "blocked": false,
    "clerkId": "user_2xM5iCisxx9uJQ3XjDJM83T0oLz",
    "createdAt": "2025-05-20T10:00:24.622Z",
    "updatedAt": "2025-05-20T10:00:24.622Z",
    "publishedAt": "2025-05-20T10:00:24.623Z"
  }
]


Output of http://localhost:1337/api/user-courses?fields[0]=documentId&fields[1]=course - gives some error

Output of http://localhost:1337/api/user-courses?fields[0]=documentId - works

course is a relation to course collection.

How do I get associated course document Id for user-course rows?
....

Output of http://localhost:1337/api/user-courses?populate[course][fields][0]=documentId&fields[0]=documentId

{
  "data": [
    {
      "id": 96,
      "documentId": "mo06lybnrsehtdrd09a5k4r9",
      "course": {
        "id": 2,
        "documentId": "exq61js9lywj287gioo2k32q"
      }
    },
    {
      "id": 208,
      "documentId": "mpzhotqasah33ub3ahkg1qsj",
      "course": {
        "id": 4,
        "documentId": "caidhct21sy7lhabvqhauho9"
      }
    },
    {
      "id": 211,
      "documentId": "yxvzzjc4o9gfru6vtswypzxe",
      "course": {
        "id": 4,
        "documentId": "caidhct21sy7lhabvqhauho9"
      }
    }
  ],
  "meta": {
    "pagination": {
      "page": 1,
      "pageSize": 25,
      "pageCount": 1,
      "total": 3
    }
  }
}
---------------
...
Does the following code have a bug:
      // Also update user-course with progress and lesson_index
      const userCourse = await getUserCourses();
      const userCourseToUpdate = userCourse.find((course) => course.course.documentId === courseId);
      if (userCourseToUpdate) {
        updateUserCourseProgress(userCourseToUpdate.documentId, progress, nextLessonIndex);
      }
-----
You see, there are multiple rows with same course.documentId in User-course collection, one for each user who is associated with a course. So we may need to search for row with a particular course.documentId for a particular user.
What do u say?
 ....

CG: Exactly — since getUserCourses() already filters by clerkId (the current user), you are guaranteed to get only the courses associated with that user. And since your app allows only one row per user-course, your find call is safe. ✅
 
...
Me: OK. I got into all this due to a strange refresh issue. ON Android app on phone, changing lesson using drawer updates STRAPI backend but My Content page still shows old progress data and IFIRC, even continuing course goes to old course. If I reload the app then My Content page shows current progress data and goes to current lesson. ... On web app, this issue is not there. ... Any thoughts?

[CG gave some analysis but it was not convincing to me ...]

Me: [CG words: "Your invalidateQueries call in lesson/index.tsx will trigger a refetch, but sometimes the refetch may be slightly delayed, and meanwhile, the UI still renders the stale cached data."] - This is very surprising to me. I mean, I expected that invalidateQuery would ensure that across the entire app any screen or component that is using that particular query will automatically refetch the data.

[CG's response was again not convincing but it provided a fix given below ...]
[In app\(app)\(authenticated)\(tabs)\my-content.tsx]
const { data } = useQuery({
  queryKey: ['userCourses'],
  queryFn: () => getUserCourses(),
  refetchOnMount: 'always',
});

[I tried the above named as Option 1.]
Me: I used Option 1. Then I had to reload the app. AFter that navigating to another lesson and then going to My Courses page, very briefly shows the old progress but immediately updates to current progress.

[But I wanted to dig in further and understand the issue. Some more discussions including me sharing code. Then CG caught the race condition and suggesed the fix which worked very well. Finally CG provided a note covering this issue which is given below with some clarifications added by me which are marked clearly as my additions.]

CG: ... 

Android vs Web: React Query Race Condition on Course Progress Update

Scenario:

  • User navigates to a lesson via drawer (external navigation) in the Android app.

  • Backend (Strapi) updates the user-course finished_percentage.

  • The My Content page sometimes shows stale progress data despite invalidateQueries() being called.

Observation:

  • Web app did not show the race condition — updates were reflected immediately.

Root Cause Analysis:

  1. React Query & async calls:

    • invalidateQueries() triggers a refetch but does not wait for the preceding async write (updateUserCourseProgress) to complete.

    • Race condition: query refetch executes before backend write finishes, leading to stale data in the UI.

  2. Platform-specific timing differences:

    • Web app: front-end and backend on same PC → fetch returns in milliseconds → write usually completes before refetch → no visible issue.

    • Android app: JS runtime (Hermes/JSC) → bridge → mobile hotspot → backend on PC → network latency + async scheduling → fetch completion delayed → race manifests clearly.

  3. Stale UI flashes:

    • On Android, even with cached React Query data, the UI renders the old progress until refetch returns updated data — flash of stale value. [Ravi: the flash of stale value happens only with the band-aid fix of refetchOnMount: 'always', in my-content.tsx. Otherwise the new value is not shown even when I waited for some minutes on my-content.tsx related screen.]

Solution:

  • Wrap the update and invalidate in an async function and await the update before invalidating queries:

(async () => { await updateUserCourseProgress(lesson.course.documentId, progress, index, true); await queryClient.invalidateQueries({ queryKey: ['userCourses'] }); })();

Outcome:

  • Ensures backend update completes before React Query refetch.

  • Eliminates stale UI flashes. [Ravi: Eliminates stale data being shown for long.]

  • Works consistently across web and Android, regardless of network latency.

Takeaway:

  • invalidateQueries() alone does not guarantee ordering with preceding async operations.

  • Platform-specific latency can expose hidden race conditions.

  • Always await critical backend writes when followed by a dependent query refetch.


...

Commit: Fixed drawer menu lesson navigation update bug for Android - race condition due to await not being used for async function

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

16:36, 08 October 2025

Did a test run for drawer navigation issue described earlier. It worked correctly both on web and on Android app on phone.

Me: In StrapiProvider.tsx we have following:
const markLessonAsCompleted = async (
    lessonId: string,
    courseId: string,
    progress: number,
    nextLessonIndex?: number
  ) => {
    try {
...
----

So clearly markLessonAsCompleted is defined as an async function.

In lesson/index.tsx we have:
const onHandleCompleteLesson = () => {
    console.log("Entered onHandleCompleteLesson");
    if (!lesson) return; // <- guard for TypeScript
    const progress = Math.floor((parseInt(lessonIndex) / (lessons?.length || 0)) * 100);

    console.log("Invoking markLessonAsCompleted with:", {
      lessonId: lesson.documentId,
      courseId: lesson.course.documentId,
      progress,
      nextLessonIndex: parseInt(lessonIndex) + 1
    });
    markLessonAsCompleted(
      lesson.documentId,
      lesson.course.documentId,
      progress,
      parseInt(lessonIndex) + 1
    );

    queryClient.invalidateQueries({ queryKey: ['lessons', slug] });
    queryClient.invalidateQueries({ queryKey: ['userCourses'] });
...
------------

The markLessonAsCompleted invocation code as well as the invalidateQueries invocation statements above are from the original tutorial code.
Technically speaking, we can have a race condition in the above too, isn't it?

CG: Yes — you are absolutely right!

The code you’ve shown can definitely cause a race condition, for exactly the same reason as before. Let’s break it down carefully:


🔍 What’s happening

You have:

markLessonAsCompleted(...); queryClient.invalidateQueries({ queryKey: ['lessons', slug] }); queryClient.invalidateQueries({ queryKey: ['userCourses'] });

Here, markLessonAsCompleted() is an async function (it returns a Promise), but you’re not awaiting it.
So the next two lines — the invalidateQueries() calls — run immediately, without waiting for the backend write to finish.


⚠️ Why this is risky

React Query’s invalidateQueries() only triggers a refetch — it does not pause execution or ensure order.

That means:

  • The refetches (lessons and userCourses) can start before Strapi has saved the updated progress.

  • The new fetch gets old data from the backend (still 40%, say, instead of 50%).

  • The cache updates with stale info.

  • When the backend write finally completes, you now have a stale cache — until the next natural refresh.

This exact pattern caused the earlier “Android race condition” problem.


✅ The correct (safe) pattern

Just like you fixed before, wrap the code in an async function or mark the caller itself async:

const onHandleCompleteLesson = async () => { console.log("Entered onHandleCompleteLesson"); if (!lesson) return; const progress = Math.floor((parseInt(lessonIndex) / (lessons?.length || 0)) * 100); console.log("Invoking markLessonAsCompleted with:", { lessonId: lesson.documentId, courseId: lesson.course.documentId, progress, nextLessonIndex: parseInt(lessonIndex) + 1, }); await markLessonAsCompleted( lesson.documentId, lesson.course.documentId, progress, parseInt(lessonIndex) + 1 ); await queryClient.invalidateQueries({ queryKey: ['lessons', slug] }); await queryClient.invalidateQueries({ queryKey: ['userCourses'] }); };

🧠 Why this fix matters

  • Web: The race may not show visibly because Strapi writes and reads happen on localhost (fast I/O).

  • Android: The bridge, async I/O, and hotspot latency amplify the delay → race becomes visible.

So yes — technically speaking, even if it seems to work fine, this is a latent bug waiting to appear under slower conditions or heavier load.


[Discussion on async function usage in StrapiProvider.tsx and in components that use them like in lesson/index.tsx ... ]

Me: So if we don't do an await for invalidateQueries and we go to some mounted screen which uses the same query, the query will return stale data till the background refetch happens.

But when the background refetch does happen, does React and Tanstack together ensure that the screen is updated with the freshly fetched data?

CG: Yes — exactly right.

Even if you don’t await invalidateQueries(), React Query (TanStack Query) will absolutely ensure that the UI eventually updates itself automatically when the background refetch finishes. Let’s go step by step to make this behavior crystal clear 👇


⚙️ Step-by-step flow (no await case)

Let’s assume:

  • Screen A (e.g., “Lesson”) updates progress and calls:

    queryClient.invalidateQueries({ queryKey: ['userCourses'] });
  • Screen B (“My Content”) is already mounted and using: const { data, isFetching } = useQuery({ queryKey: ['userCourses'], queryFn: getUserCourses });

Here’s what happens internally:


invalidateQueries() is called

  • The ['userCourses'] query in the cache is marked as stale immediately (synchronous).

  • TanStack schedules a background refetch.

  • The function then returns immediately (if you didn’t await).

🧠 The query’s cache entry now looks like this:

{ state: { data: /* old progress */, status: 'success', fetchStatus: 'fetching', isInvalidated: true } }

② The background refetch starts

TanStack automatically re-runs getUserCourses().
Since your “My Content” screen already has useQuery(['userCourses']) mounted, it stays subscribed to this cache entry.

React Query emits internal state updates:

  • isFetching: true

  • data: oldData (still shown until the new fetch resolves)

Your UI might show a spinner or shimmer here — depending on your config.


③ Refetch completes successfully

The new data (e.g., progress: 60%) is written to the cache.
TanStack then notifies all components subscribed to that query key.

This includes:

  • “My Content” page on Android/web

  • Any other component with useQuery(['userCourses'])


④ React re-renders automatically

React Query uses a useSyncExternalStore-based subscription under the hood (in v5).
That means as soon as the cache changes, React automatically re-renders any component using that data.

So the “My Content” screen updates instantly when the fresh data arrives — no manual setState() or re-navigation needed.


⚠️ The only issue without await

While the eventual refresh is guaranteed, the timing isn’t:

  • If network latency is high (like on mobile hotspot), the UI will show stale data for longer.

  • If user navigates away and comes back before the background refetch finishes, they might still see old data briefly.

[Some more discussion ...]

Me: ... So the nuance here is that:

A) if we only do await for updateUserCourseProgress and not for invalidateQueries that follow then we may get short-lived stale data for the same query.

B) If we do not await for updateUserCourseProgress and do not await for invalidateQueries that follow then we may get long-lived stale data for the same query (won't change at all unless user initiates some action that refetches the query data). That is clearly a bug in the app showing wrong data to user.

C) If we do await for updateUserCourseProgress and await for invalidateQueries that follow then we are assured to get fresh data for the same query used by any component. The downside is that the app will be blocked till the fresh data is retrieved.

Is my above analysis correct?

CG: Yes — your analysis is spot on! Let me just elaborate slightly for full clarity:


✅ Your points in context

A) Await only updateUserCourseProgress, not invalidateQueries

  • Backend update completes first → the new progress is in Strapi.

  • invalidateQueries fires without awaiting → React Query starts the refetch in the background.

  • UI may briefly render stale data until the refetch finishes.

  • This is short-lived stale data — user will see old value only for a short moment.

  • Usually tolerable, not a bug, just a minor UX glitch.


B) Do not await either updateUserCourseProgress or invalidateQueries

  • invalidateQueries fires immediately, before backend update completes.

  • React Query refetches → gets old progress from backend.

  • Cache updates with old data → React Query thinks it’s fresh.

  • Backend update completes later, but query cache is already “fresh” → React Query does not automatically refetch again.

  • Result: long-lived stale data — the wrong progress is displayed indefinitely.

  • This is clearly a bug in the app, because the user sees incorrect information.


C) Await both updateUserCourseProgress and invalidateQueries

  • Guarantees backend update completes first → new progress in Strapi.

  • invalidateQueries waits for refetch → cache updates with correct data.

  • UI components reading that query immediately see fresh data.

  • Slight downside: app is temporarily “blocked” until both network calls finish, but it ensures data consistency.

  • This is the technically correct and safe approach.


...

Me: Now you have said, C is the technically correct and safe approach. Hmm. So you mean that it is better to have the user wait for fresh data before being shown, say the My courses screen in our case, than show the My courses screen right away but possibly with stale data which gets updated in a short period of time.

CG: Exactly. Option Cawait updateUserCourseProgress and await queryClient.invalidateQueries — guarantees that by the time the user sees the next screen (like My Courses), the query has already refetched and contains fresh data.

The trade-off is a slightly longer wait for the UI to render. But in cases like your app, where showing stale course progress could confuse the user, correctness and data consistency outweigh minimal speed gains.

So yes, it’s better to make the user wait a fraction longer for guaranteed fresh data than to risk showing outdated progress, even briefly.

Me: Ah! So it is an app functionality based decision on what is more suitable - C or A. If showing user some new screen ASAP is what is needed the option A may be more suitable, with an extra step of using an in-progress icon for the data that is being retrieved so that stale data is never shown.

CG: Exactly! It’s a UX/design decision.

  • Option A (await only updateUserCourseProgress, not invalidateQueries) gives a faster screen transition. But to avoid showing stale data, you’d typically show a loading spinner or skeleton UI while the query refetches in the background. That way, the user doesn’t see incorrect data.

  • Option C (await both) guarantees data correctness at the cost of a slightly longer wait before rendering the screen. No additional loading indicators are strictly needed because the screen only appears once data is fresh.

So it really depends on whether your priority is speed of UI response or guaranteed correctness at render time. You can even combine both strategies for different parts of the app.

Me: In our case, the 'skeleton UI' is actually almost the full UI except for the progress percentage figure which can be made a suitable mini-spinner icon.

CG: Exactly! That’s a perfect fit for Option A.

You can render the entire “My Courses” UI immediately, and only replace the progress percentage with a mini-spinner or shimmer while the userCourses query refetches. Once the data arrives, you swap in the correct percentage.

This gives the user an instant screen load without risking showing stale progress, and it’s much less intrusive than a full-page loader. It’s a classic pattern in React/React Native apps for async data that updates small parts of the UI.

Me: What is a shimmer in this context

CG: In this context, a shimmer is a lightweight loading placeholder that indicates data is being fetched. Instead of leaving a blank space or showing incorrect/stale data, you display a visual effect—usually a grey or gradient animated bar or shape—to mimic the layout of the content that will appear.

For example, in your “My Courses” screen:

  • Instead of showing 10% finished (stale progress), you show a small animated shimmer bar or mini spinner in place of the percentage.

  • Once the fresh userCourses data arrives, the shimmer is replaced by the correct percentage.

It’s a common UX pattern in modern apps (used in Facebook, Instagram, etc.) to communicate that content is loading without blocking the rest of the UI.

In React Native, you can implement it using libraries like react-native-shimmer-placeholder or just a small Animated.View gradient.

...

Me: https://github.com/tomzaku/react-native-shimmer-placeholder has a great README showing shimmer usage via animation. Got it. Thanks.

...

Me: Using Option A without a shimmer or mini-spinner and thus showing stale data briefly would be viewed as an immature UI by tech pros. Am I right?

CG: Yes, that’s exactly right. Experienced developers consider showing stale or placeholder data without any visual indication of loading as a UI “anti-pattern.” Even if the data updates shortly after, users can be confused or think the app is buggy.

A small shimmer, mini-spinner, or subtle placeholder for the changing part (like your progress percentage) signals that the app is actively fetching fresh data. That keeps the UI feeling polished, responsive, and professional—even if the rest of the screen renders immediately.

Me: Very useful discussion. It is a good learning for me.

Now back to tutorial code. There are only two or three or maybe four data modifier functions in StrapiProvider.tsx. We don't need to worry about the get functions as surely the callers would be waiting for the data and so use await either through tanstack or directly.

We need to only go through the data modifier functions and ensure that await is used where appropriate and thus prevent possibility of some race condition bugs cropping up.

CG: Exactly! That’s a very practical way to audit your code. You don’t need to overthink the read/get functions—they naturally return promises, and the caller (through React Query or a direct await) usually handles them correctly.

The key focus should be on data-modifier functions in StrapiProvider.tsx that change backend state:

  1. markLessonAsCompleted – Already async; just ensure all dependent actions that rely on updated backend data (like updateUserCourseProgress or invalidateQueries) are properly awaited to avoid race conditions.

  2. updateUserCourseProgress – Already async; callers should await it when the UI must reflect the updated state immediately.

  3. addUserToCourse – Async; the caller should await it if subsequent UI or query invalidation depends on the new user-course data.

  4. createUser – Async; usually called during signup/registration, so it’s important to await the full operation before moving to the next step (like navigating to a new screen or fetching related data).

In short:

  • Any function that modifies backend data should be awaited when the UI depends on that data being fresh.

  • Query invalidations (invalidateQueries) should also be awaited if the subsequent screen or component relies on the updated query.

  • Pure getters (getCourses, getLessonsForCourse, etc.) don’t need special treatment beyond what React Query already handles.

This approach keeps your app safe from race conditions while avoiding unnecessary blocking where it’s not needed.

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

Me: In following code: const { data: lessons, isLoading: lessonsLoading } = useQuery({ queryKey: ['lessons', slug], queryFn: () => getLessonsForCourse(slug as string), }); Does tanstack useQuery expect queryFn to be an async func. and does it use it with an await?

CG: Yes — here’s the detailed explanation:
  1. queryFn can be async or return a promise.
    In your code:

    queryFn: () => getLessonsForCourse(slug as string)

    If getLessonsForCourse is async (as it is), it returns a promise. TanStack Query handles this perfectly.

  2. How TanStack uses queryFn:
    Internally, TanStack Query automatically awaits the promise returned by queryFn. You do not need to manually await it here. It looks roughly like this under the hood:

    const result = await queryFn(); // TanStack awaits the promise
  3. What this means:

    • queryFn returning a promise is expected and standard practice.

    • data will be populated with the resolved value of the promise.

    • isLoading will be true until the promise resolves.

    • If the promise rejects, TanStack sets isError to true.

✅ So your code is correct. TanStack Query does the awaiting internally, which is why you can just pass an async function (or a function returning a promise) as queryFn.

Me: I now am examining StrapiProvider.tsx to see which functions in it need to change to add await and avoid race conditions issue. 
First I want to discuss a get function. Here's the code:
  const getCourses = async (): Promise<Course[]> => {
    try {
      const response = await fetch(`${baseUrl}/api/courses?populate=image`);

      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }

      const result = await response.json();

      result.data = result.data.map((item: any) => {
        const image = (__DEV__)
        ? `${baseUrl}${item.image.url}`  // In Development
        : `${item.image.url}`
        return {
          ...item,
          image,
        }
      });
      // result.data = result.data.map((item: any) => ({
      //   ...item,
      //   image: `${item.image.url}`,
      // }));

      return result.data;
    } catch (error) {
      console.error('Error fetching data from Strapi:', error);
      throw error;
    }
  };
------------------

The implementation seems to be good. As data is being returned, response.json is also awaited.

CG agreed and said: 
This getCourses function is perfectly implemented — no race conditions, no missing awaits, no hidden promise returns.

Me: In my quick look at the other get functions, they are all done similarly with proper awaits.
Let share the whole file code with you. But as of now let us focus on get functions only. ....

CG did a detailed check and said in summary:
✅ Conclusion for all get functions
All of them are correctly implemented — no hidden race condition potential, no missing await.
They all safely return resolved values to TanStack Query or direct callers.
Hence, they cannot produce stale or inconsistent data on their own.

Me: Now let us fix markLessonAsCompleted . Please provide your suggestions

....

Me: Your code misses some of the original code. Here's my version with awaits added. Pls check it:
  const markLessonAsCompleted = async (
    lessonId: string,
    courseId: string,
    progress: number,
    nextLessonIndex?: number
  ) => {
    try {
      const response = await fetch(`${baseUrl}/api/progresses`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          data: {
            lesson: lessonId,
            course: courseId,
            clerkId: user?.id,
          },
        }),
      });

      if (!response.ok) {
        throw new Error(`HTTP error! status: ${response.status}`);
      }

      // Also update user-course with progress and lesson_index
      const userCourse = await getUserCourses();
      const userCourseToUpdate = userCourse.find((course) => course.course.documentId === courseId);
      if (userCourseToUpdate) {
        await updateUserCourseProgress(userCourseToUpdate.documentId, progress, nextLessonIndex);
      }
      await queryClient.invalidateQueries({ queryKey: ['userCourses'] });

      return response.json();
    } catch (error) {
      throw error;
    }
  };
....
CG: Nice — this is already much better. You added the critical await for updateUserCourseProgress and for invalidateQueries, which is exactly what was needed to avoid the race.

Two small things to call out (one correctness/consistency item, one small polish), and then I’ll give two short, ready-to-drop variants you can pick from. 

[CG pointed the return statement mismatch with StrapiContextType specifying that it returns Promise<void> and provided suggestions. I had wanted to discuss this next anyway. CG also pointed out lack of await in return response.json(); ]

Me: I will go for option B and return nothing. But here why can't I avoid doing the await response.json() as the function does nothing with any returned data?

CG: ... 

🧠 Why await response.json() is usually still done

When you call fetch(), you get a streaming HTTP response object.
The response body isn’t automatically read — it’s a readable stream that must be consumed (via .json(), .text(), .arrayBuffer(), etc.).

If you don’t read it:

  • The connection may stay open briefly until the body times out or is garbage-collected.

  • In browsers and React Native’s fetch implementations, this can lead to dangling network handles or warnings (“body stream not consumed”).

  • In practice, these are small, but in apps doing many such requests, it can affect network throughput or caching.

So:
Best practice is to consume or explicitly discard the response body before returning.

...

CG: ➡️ You should either consume the response body, or explicitly discard it.

Let me restate that clearly:


✅ Two clean ways to “finish” the response

  1. Consume it (read it fully):

    await response.json(); // or .text(), etc.

    This reads the body stream completely — letting the network layer close the connection cleanly.

  2. Explicitly discard it (without parsing):

    await response.arrayBuffer(); // reads & discards bytes fast

    or even:

    await response.body?.cancel(); // aborts remaining body stream

    This tells the runtime: “I’m done with this response; don’t keep this connection hanging.”

....

Me: I think await response.body?.cancel();
seems to be a better option that consuming the response. It will be more efficient and not impact functionality.

CG: Exactly ✅ — you’ve reasoned that perfectly.

👉 await response.body?.cancel() is more efficient because:

  • It immediately closes the response stream without reading or buffering data.

  • It frees the connection slot faster (important when multiple parallel fetches happen).

  • It avoids unnecessary JSON parsing and CPU work when the body isn’t needed.

  • Functionality remains identical if you don’t depend on the response content.

So, your function can elegantly end like this:

if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } await response.body?.cancel(); // discard body efficiently

And then just return void.

That’s a very clean, technically solid pattern for “fire-and-forget” POSTs or PUTs.
It’s actually how many telemetry or update endpoints are handled in production apps.

...


Me: It was also interesting to know about:
a HEAD or 204 No Content optimization on the backend side so that the server doesn’t even send JSON for such “mark complete” calls? (That would make skipping .json() 100% clean.)
That I think is even more efficient and clean.
But for this tutorial code, I will go with await response.body?.cancel(); // discard body efficiently
...

Me: Your below point is noted:
Extra note about double invalidation

If your caller (e.g., onHandleCompleteLesson) also does queryClient.invalidateQueries(['userCourses']), you’ll do duplicate invalidations. That’s not harmful but redundant.

Decide whether invalidation belongs inside markLessonAsCompleted (centralized) or at the caller (caller controls subsequent flow). Either is fine; be consistent.
----------------
But I want to go with tutorial code in this case as it is only an inefficiency issue. Else I will have to ensure that all cases do not miss out on invalidate.

[CG agreed.]

----------
Made changes in markLessonAsCompleted. Tested it on Android app and web app for navigation use case. It worked.
Now I am deleting Progress(es) collection rows in STRAPI for last two lessons of Beginner's Yoga ... course (Relaxation & Meditation and Final Flow & Graduation). That can be done using filters in the admin UI (contains (case insensitive)). I am doing it for both users and deleting all such rows. Changing entries per page to 50 makes it easy to delete these rows at one go. ... Done.
Now in Android app on phone and web app (different users), the above two lessons are NOT marked as completed. ... Now I can test whether markLessonAsCompleted function is correctly marking them as completed.

It is not marking them as completed even though the related rows are getting added to Progresses! Even reload of web and Android app does not show them as completed!

For debugging, I am finding Inspect -> Network -> Fetch in web app to be useful as I can see the api url and the data returned.
It seems that the url: http://10.50.171.151:1337/api/progresses?filters[course][slug][$eq]=beginner-course&filters[clerkId][$eq]=user_2xBpZwuCMK7AUEKRFT4Q1hcm9uk&populate=lesson
is returning only 1 page of 25 entries with last entry having id as 238.
In STRAPI admin UI, 25th entry (sorted on id ascending) is shown with id as 237 (and the next entry is 239). Looks like there is a difference of 1 between STRAPI admin UI and api returned data.
The total entries for this filter in STRAPI admin UI is 49 with my latest entries coming after 45 or so. 
If the API returns only 25 entries then the getUserCompletedLessonsForCourse() method in StrapiProvider.tsx will not find my latest entries. Perhaps that's why it does not show the entries as completed in the UI.
....

[CG confirmed that it is a pagination issue in STRAPI and suggested some fixes like increase page size.]
...

The off by 1 is consistent across rows. Another point I observed is STRAPI UI shows (for progresses collection) only odd number id values (1,3 etc.) whereas the API has only even id values (2, 4 etc.). Strange! But I don't want to invest too much time in figuring this out.
....

Now about the issue and the fix: I think this is an app design issue. Why have so many progresses entries? Why repeat entries for the same lesson, course and user? .... For now, I will log this limitation on my blog and live with this limitation in the app. .... And for my current testing, I will simply delete all rows/entries from progresses collection and then test the app.

[CG agreed.]

After deletion of all progresses entries, markLessonAsCompleted correctly marked lessons as completed both on web app and on Android app on phone. Further there does not seem to be any noticeable delay introduced by the added await statements.

Commit: Fixed race condition vulnerability in markLessonAsCompleted in StrapiProvider.tsx

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

17:24, 09 October 2025

Now moving to lesson/index.tsx,
I added await to markLessonAsCompleted call and the following queryClient.invalidateQueries call statements. 
I had to do that in two functions: onHandleCompleteLesson and onEndCourse. 
To avoid making the handlers async, I used this style that was used in the useEffect.
    (async () => {
      await updateUserCourseProgress(lesson.course.documentId, progress, index, true);
      await queryClient.invalidateQueries({ queryKey: ['userCourses'] });
    })();    

This way the handlers can continue to be sync.

Me: Execution wise, at the first await that the IIFE contains, the IIFE will return with a promise, and rest of code of container function will execute. Am i right?

[CG agreed.]

Back to StrapiProvider.tsx. For updateUserCourseProgress, I only had to add:
 await response.body?.cancel

[CG agreed.]

In case of createUser, the code in StrapiProvider.tsx did not need any change. The only place it is called is in login.tsx where the code is:
 await createUser(strapiUser); 
So looks like there is no race condition issue here.

[CG agreed.]

In case of addUserToCourse, I had to add an await before queryClient.invalidateQueries.
I don't think any other changes are needed. 
The function is called from two other code files, with each file calling it in two separate places. 
All these 4 invocations use await. 
So no changes to be made on caller side as well.

[CG agreed.]

...

Me: I think now all modifier functions in StrapiProvider.tsx do not create any race condition bugs. The get functions were OK earlier on itself. Here's the code of the entire file. Please have a look. [Shared code]

CG: ...

4. Conclusion

  • All modifier functions now properly await network calls and query invalidations, so there should be no race condition bugs in the current implementation.

  • Get functions are read-only and safe.

  • Your handling of response.body?.cancel() vs response.json() is consistent with best practices.

-------

Deleted Beginner's Yoga entries for both users in User-course in STRAPI backend. That resulted in those courses not being part of user's courses in the app (My Courses).
Tested web and Android app on phone for navigation via drawer, add course to user, and also lesson completion. It worked OK.

Commit: Fixed race condition possibilities in all modifier functions in StrapiProvider.tsx and in calling code from other files/components
=================================

12:38, 10 October 2025

Started next segment: Adding a Waitlist Page and User Profile https://www.youtube.com/watch?v=fO3D8lNs10c&t=15810s

First part of this segment does the User Profile part.
The recent activity list in profile is fake. See app\(app)\(authenticated)\(tabs)\profile.tsx Ref: https://youtu.be/fO3D8lNs10c?t=15930 .
I should consider commenting out the fake activity list.

Waitlist part starts: https://youtu.be/fO3D8lNs10c?t=15947
Clerk screen shows that for Waitlist, "Sign ups are disabled, but people can join a waitlist." Ref: https://youtu.be/fO3D8lNs10c?t=15966
SG initially says that it is enabled only for the web but later uses a 'trick'/hack that makes it work for both web and mobile (given below).

I don't have the waitlist related pages in my app. The additional files to be picked up from the SG original repo (or my build-failed-lms-react-native folder) are:
app\soon.tsx Repo URL: https://github.com/Galaxies-dev/lms-react-native/blob/main/app/soon.tsx
app\wait.tsx Repo URL: https://github.com/Galaxies-dev/lms-react-native/blob/main/app/wait.tsx
-----

SG tests the web app Waitlist feature by going to /soon page of the app using browser URL when user is logged off.
My app's app\_layout.tsx already has needed change of addiing waitlistUrl to ClerkProvider tag/element:
  return (
    <ClerkProvider
      tokenCache={tokenCache}
      publishableKey={publishableKey}
      waitlistUrl="http://localhost:8081/">
      <GestureHandlerRootView style={{ flex: 1 }}>
...
----
Ref: https://youtu.be/fO3D8lNs10c?t=16039

SG could not get it to work with above code. He then adds the following to top of app\soon.tsx:
'use dom';
----
Ref: https://youtu.be/fO3D8lNs10c?t=16128
Note that repo soon.tsx does not have this line.

With the above 'trick', the waitlist shows on both web and mobile. Ref: https://youtu.be/fO3D8lNs10c?t=16146

At https://youtu.be/fO3D8lNs10c?t=16157 SG wraps the Page component of soon.tsx in ClerkProvider component. This is not done in the repo soon.tsx.
SG says that results in the web app waitlist page content centered in middle of page.
[Later when I did the above, and tried going to http://localhost:8081/soon, I got the error message: "@clerk/clerk-react: You've added multiple <ClerkProvider> components in your React component tree. Wrap your components in a single <ClerkProvider>."
So I commented out the <ClerkProvider> tags.]

Similarly at https://youtu.be/fO3D8lNs10c?t=16199 SG adds to soon.tsx:
import '@/global.css';
---
which he says improves the web display.

https://youtu.be/fO3D8lNs10c?t=16218 shows how the users added to waitlist can be viewed and how those user(s) can be accepted.

SG does not seem to clearly state that if waitlist is enabled in Clerk, and user is logged out, going to the app url or opening app in mobile will automatically go to waitlist page.

I think it is worth trying implementation of the waitlist feature and see whether it works and how it works.
------------

Commit: Commented out dummy recent activity list from profile

Now I will try out implementing the waitlist feature. I plan to do a commit only if it works. This way, if it doesn't work, I can easily discard changes and be at above commit point.

Clerk dashboard -> Configure -> Restrictions -> Sign-up mode has Waitlist option (instead of Public or Restricted).
I changed it to Waitlist from Public.

Copied soon.tsx and wait.tsx from build-failed-lms-react-native to lms project.
Added to soon.tsx:
'use dom';
---

Logged out on web. The standard login process (Google a/c) logged me in on web and showed me my courses.
Logged out again on web and in Google login process, chose Use another account and used ek.... 
Web app continued to be on login page (perhaps as I had to do verification via SMS for above Google login)
This time ek... a/c was one of the options listed in Google. I chose it taking me back to web app quickly but on login screen only. So I did not see the soon/waitlist pages.
[Later update: Initially I thought that this was the way to use the Waitlist feature and that using an a/c that is not registered would automatically take me to Waitlist page. Later I realized that this does not seem to be how the Waitlist feature is to be used. That is covered in a note later on in this post. I am retaining some part of the immediate notes below as it may still be useful in some other context.]

Browser console shows multiple instances of this error:
Cross-Origin-Opener-Policy policy would block the window.closed call.
---------------

I went through same routine on mobile app. It too did not work. It showed an Unmatched Route screen with myapp:///sso-callback as the route.
Clicking go back option on it took me back to login screen.
Now trying out login with my regular android app a/c (which is already registered in clerk as a user). It took me to home page (after briefly showing me some error screen which I think was Unmatched route). I am able to go to my course and view its lessons.
For mobile app, Metro console does not show any new log messages.

Back to web app. I now logged in using a Google a/c already registered as user in clerk. It logged me in and showed me my courses.
Strangely, browse console had 4 instances of this error message:
 Cross-Origin-Opener-Policy policy would block the window.close call.
----
So both mobile and web app continue to work for registered users but does not work for new users when using waitlist feature.

============
If I try http://localhost:8081/soon on web, I am shown the waitlist page.
I added ek... to the waitlist page.
I am shown screens same/similar to what SG shows in the video and then taken to http://localhost:8081/wait which shows the message correctly.
Clerk dashboard waitlist lists ek....
I accepted above entry. It did not get added to All users but got added to Invitations tab. I was not shown a command to Accept the Invitation (but i could revoke the Invitation)... See entry below about email invitation having 'Accept Invitation' button.
...

I later saw that I got an email: "[Development] You're on the lms-video waitlist" sent to ek... but which I received on my regular gmail a/c as I have auto-forwarding set up.

Then I saw that under Promotions tab, there was an "[Development] Invitation to join lms-video" email. It had an "Accept Invitation" button.

I switched to ek... a/c in gmail. In its Promotions tab, opened above mail and clicked on 'Accept Invitation'. I went through some screens there choosing Google and ek... a/c again.

It showed me a screen with the message: Welcome, Ek...
You are signed in. Now, it's time to connect Clerk to your application.
----
It had some 'Start Building' or similar button clicking which took me to Clerk dashboard of my regular a/c! I found that strange! [I think since I was already logged on to to Clerk dashboard, I got taken to it.]

Anyway, now ek... was added to All Users. The Invitations and Waitlist tabs had no users.
...
Now on web app, I was able to login using Google a/c of ek... and was shown no entry in My Courses, and Browse courses listed the 2 available courses. This functionality seems to be working as expected.
--------------

Should my app's app\_layout.tsx have waitlistUrl in ClerkProvider tag/element as follows?
  return (
    <ClerkProvider
      tokenCache={tokenCache}
      publishableKey={publishableKey}
      waitlistUrl="http://localhost:8081/soon">
      <GestureHandlerRootView style={{ flex: 1 }}>
...
https://youtu.be/fO3D8lNs10c?t=16039 shows how SG deletes the soon part (probably suggested by AI).

I made above change and deleted ek... user from Clerk dashboard.
It doesn't work. I still am taken back to login screen on web app when I try logging in with ek....

I changed it to waitlistUrl="/soon">
That too did not work.
....

https://clerk.com/docs/guides/secure/waitlist has the following for a Next.js implementation:
    <ClerkProvider waitlistUrl="/">
      <html lang="en">
        <body>{children}</body>
      </html>
    </ClerkProvider>
----
The / page has the Waitlist component.
-----------

....

I saw some videos on the net, and also thought about the documentation I had read. I think all the examples I saw had the waitlist component on the home page. Further, the waitlist component shows a login link. So the process then becomes that the user has to be shown the waitlist component on the home or landing page. It is not that the waitlist component or page is shown automatically when user logs in with a Google a/c that is not registered as a user, which is what I thought it may be doing and what I was testing the app for.

In both the examples below, IFIRC, clerk middleware for authentication is used. But that, I think, does not apply in our case as we have our own auth logic.
Set up a waitlist in your Next.js app, https://clerk.com/docs/guides/secure/waitlist
Building a waitlist system in NextJs, https://www.youtube.com/watch?v=9xKiJJMrDv0 , 24 min. 24 secs., Feb. 2025

From the examples, some of which use "/" as the waitlistUrl, it is clear that in our app we should use:
      waitlistUrl="/soon"
----
But I don't know when exactly this waitlistUrl gets used. But that may not be important, as of now.

So if in our app we want to mimic above examples which seem to reflect how the waitlist component is to be used, then we should replace the login page with the Waitlist component page! That may be going too far especially since official clerk documentation states that Waitlist component cannot be directly used for Android/iOS in Expo. SG's 'trick' of using 'use dom' and trying it out on mobile may be OK from learning point of view but not suitable for real app.

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

Me: In a React Native and Expo app, I want to open the link: http://localhost:8081/soon .
That is easy to do for web app. But for dev client on Android app on phone, how do I do that?

[ChatGPT gave many suggestions in the exchange that followed ...]

[adb suggestion needed one-time connection via USB to phone. I did not want to do that. Note that adb works on emulator as well as android app on phone provided an ADB-over-Wi-Fi connection or something is set up.]

npx expo start --initial-url exp://10.50.171.151:8081/--/soon
Above did not work giving message:
unknown or unexpected option: --initial-url
----

On mobile chrome , specifying exp://10.50.171.151:8081/--/soon
or variations like
exp://10.50.171.151:8081/
does a Google search on it.
...

The Metro console shows: 
Metro waiting on exp+lms://expo-development-client/?url=http%3A%2F%2F10.50.171.151%3A8081
› Scan the QR code above to open the project in a development build. Learn more: https://expo.fyi/start

...
exp+lms://expo-development-client/?url=http%3A%2F%2F10.50.171.151%3A8081
Above command when specified in mobile chrome browser opened the app on home page

Below commands specified in mobile chrome browser did not work (IFIRC, the expo dev client opened in at least some attempts but gave some error):
exp+lms://expo-development-client/?url=http%3A%2F%2F10.50.171.151%3A8081%2F--%2Fsoon
exp+lms://expo-development-client/?url=http%3A%2F%2F10.50.171.151%3A8081/--/soon

================
Me: As I failed to get this done using dev client or mobile browser, I am now trying changes in code. Is the below code in the main _layout.tsx file for the project, OK?
  useEffect(() => {
    if (loaded && isLoaded) {
      SplashScreen.hideAsync();
      router.replace("/soon");      
    }
  }, [loaded, isLoaded]);
...
[CG was OK with my trying out above code.]

Me: That worked but there was another strange issue .... [I shared code of key files with CG.]
...

Me: ... Now about the strange problem. As the code is now, the web app shows the waitlist properly.
But Android app on device says "@clerk/clerk-react: Waitlist can only be used within the <ClerkProvider /> component. Learn more: https://clerk.com/docs/components/clerk-provider".

If I wrap soon.tsx code in ClerkProvider component, Android app shows the waitlist (with some styling issues) but now web app gives an error that ClerkProvider component is used twice.

CG: ... This is a classic “platform mismatch with ClerkProvider” issue in Expo Router + React Native + Web. Let’s go slowly.

1️⃣ The root cause

  • _layout.tsx already wraps the entire app in <ClerkProvider> (good).

  • soon.tsx uses Waitlist from @clerk/clerk-react.

  • On web, @clerk/clerk-react requires that <Waitlist> be rendered inside a ClerkProvider — which it is, via _layout.tsx. ✅ Works fine.

  • On Android (Expo Dev Client), you are using @clerk/clerk-expo + @clerk/clerk-react imports.

The problem is that @clerk/clerk-react’s Waitlist cannot find a ClerkProvider in the native environment, because the global _layout.tsx ClerkProvider is a @clerk/clerk-expo provider, not the same React context expected by @clerk/clerk-react.

Hence the error:

@clerk/clerk-react: Waitlist can only be used within the <ClerkProvider /> component
  • Wrapping soon.tsx in ClerkProvider fixes native (Android) because now Waitlist sees the correct provider.

  • But on web, _layout.tsx already provides ClerkProvider, so now you have two providers, which React doesn’t allow → error.


2️⃣ Why the web vs Android difference

  • Web: @clerk/clerk-react expects ClerkProvider from @clerk/clerk-react

  • Expo Native: @clerk/clerk-expo is needed, and Waitlist imported from @clerk/clerk-react does not find a provider because the native provider is not compatible.

So you basically have a “provider mismatch”: the same Waitlist component behaves differently depending on platform.


3️⃣ Correct solution

You need to conditionally import and render platform-appropriate Waitlist:

[--- omitting code as that did not work out ----]
...

Me: very interesting analysis ... before I try the solution you have suggested ... two points:
1) In soon.tsx, I changed it to use:
import { Waitlist } from '@clerk/clerk-expo/web'
...
Let me see if that makes a difference.
2) https://clerk.com/docs/expo/reference/components/authentication/waitlist says, "This component can be used in Expo Web projects, but it isn't available in native environments (iOS or Android). For native apps, build a custom UI using Clerk hooks. See custom flows guides for details."
-----
But the tutorial author says he is using the 'use web' trick to get it working on mobile.

CG: Ah — now everything makes sense! Thank you for these clarifications — we can connect all the dots.

1️⃣ About @clerk/clerk-expo/web + 'use web'

  • Importing Waitlist from @clerk/clerk-expo/web is essentially a “web-only” component trick.

  • The tutorial author is probably relying on React Native for Web to render the web component inside the Expo Dev Client.

  • In pure native mobile (iOS/Android), this isn’t officially supported. That’s why the Clerk docs say: “This component can be used in Expo Web projects, but it isn't available in native environments (iOS or Android). For native apps, build a custom UI using Clerk hooks.”

  • So your use web trick may work in Expo Dev Client for development/testing, but it’s not guaranteed on real native builds (like EAS Build on device).


2️⃣ What that means practically

  • On web: Waitlist works normally. ✅

  • On Expo Dev Client (mobile): Waitlist may sometimes fail because the “web trick” requires the device to handle web rendering inside the RN environment. That explains why you saw:

@clerk/clerk-react: Waitlist can only be used within the <ClerkProvider /> component
  • Wrapping soon.tsx in ClerkProvider fixes it temporarily because now the component finds a provider, but then web throws “provider used twice” because _layout.tsx already has one.


3️⃣ Official approach for mobile

Clerk’s docs recommend:

  1. Do not use Waitlist component on native mobile.

  2. Instead, build a custom waitlist screen using Clerk hooks, like:

import { useSignUp } from '@clerk/clerk-expo';
  • Then you can implement your own form/UI to collect emails for waitlist.

  • Works consistently across web and native.


4️⃣ Why your platform-specific approach works

The pattern I suggested earlier — conditional imports:

if (Platform.OS === 'web') {
  const { Waitlist } = require('@clerk/clerk-react');
} else {
  const { Waitlist } = require('@clerk/clerk-expo');
}
  • On web, uses the official Waitlist. ✅

  • On mobile, uses Expo-native hooks (useSignUp) and your custom UI. ✅

This aligns with Clerk’s official recommendation and avoids “double provider” errors.


5️⃣ Key takeaway

  • The use web trick is hacky — works in Expo Dev Client sometimes, but not officially supported.

  • For a robust solution across web + Android + iOS:

    • Web: use Waitlist component

    • Mobile: use custom waitlist form built with Clerk hooks (useSignUp etc.)


If you want, I can rewrite your soon.tsx as a cross-platform waitlist that:

  1. Uses the Clerk Waitlist on web.

  2. Uses a custom native form on Android/iOS.

  3. Works with _layout.tsx’s single ClerkProvider.

This will eliminate both the provider errors and platform-specific bugs.

Do you want me to do that?

[CG then provided code but that did not work ...]

Me: The waitlist form is shown correctly both on web and mobile with your soon.tsx code. Add a user on web waitlist goes through with entry appearing in waitlist on clerk dashboard.
But adding on Android app, shows an error: "Sign ups are currently unavailable Join the waitlist ...". Pressing OK does not add an entry to clerk dashboard waitlist.

CG: Ah — now the situation is clear. This is expected behavior given how Clerk handles waitlist in native apps. Let’s go carefully.

1️⃣ Why Android mobile fails

  • You are using native useSignUp() from @clerk/clerk-expo.

  • Clerk’s waitlist restriction feature is web-only.

  • On native apps, if Waitlist Restriction is enabled: “Sign ups are currently unavailable. Join the waitlist in web.”

    This is exactly the error you are seeing.

  • Key point: Clerk does not automatically add native users to the waitlist. The mobile SDK does not create waitlist entries — it only allows signups if Waitlist Restriction is off.


2️⃣ What happens

  • On web:

    • Waitlist component handles waitlist creation automatically → entry appears on dashboard ✅

  • On Android/iOS:

    • signUp.create({ emailAddress }) fails because native SDK enforces waitlist restriction

    • You see the modal error "Sign ups are currently unavailable..."

    • Nothing is added to Clerk dashboard ❌


3️⃣ Clerk’s official recommendation

From Clerk docs“This component can be used in Expo Web projects, but it isn’t available in native environments (iOS or Android). For native apps, build a custom UI using Clerk hooks.”

  • The custom UI does not automatically add users to the waitlist — you have to implement a backend endpoint that sends the email to your own waitlist table.

  • Native users cannot be automatically queued on Clerk dashboard.


4️⃣ How to handle this in your app

Option A — Native users redirected to web waitlist page

  • In your mobile form:

Alert.alert(
  'Join the waitlist',
  'Sign ups are currently unavailable on mobile. Please visit our web waitlist to join.',
  [
    { text: 'Open Web', onPress: () => Linking.openURL('https://yourapp.com/soon') },
    { text: 'Cancel', style: 'cancel' },
  ]
);
  • This is what most apps do — mobile users go to web for waitlist.

Option B — Custom backend waitlist

  • Create a serverless function or API endpoint in your backend (or Strapi) that saves email addresses.

  • On mobile, submit form via fetch('/api/waitlist', { method: 'POST', body: { email } }).

  • This keeps mobile waitlist entries visible in your own database, independent of Clerk.


5️⃣ Key takeaway

  • Web: Clerk Waitlist works perfectly.

  • Mobile native: Waitlist cannot be automatically created in Clerk dashboard.

  • Any attempt to signUp.create() while Waitlist Restriction is ON → blocked.


If you want, I can update your mobile native waitlist form to:

  1. Detect Waitlist Restriction is active

  2. Show a message and redirect to web waitlist OR

  3. Send email to a backend waitlist collection

This way your mobile users can join the waitlist even though Clerk doesn’t allow native waitlist entries.

Do you want me to do that?

Me: https://clerk.com/docs/reference/javascript/clerk#join-waitlist indicates that there is JS API to join the wait list.

CG: You're correct — Clerk does provide a JavaScript API to add users to the waitlist, but it's important to note that this API is intended for use in server-side environments, not directly within client-side code running on mobile devices. ...

[I then asked CG for earlier Option A implementation code. Had some interations with CG provided code. ... ]

Me: That worked. Some questions and points:

1) Mixing require and import - is that acceptable? Could we use a soon.tsx and soon.web.tsx approach to avoid that?

2) I will modify button label to indicate it is join waitlist via web.

3) On Android, should we provide an additional button to Exit App on this waitlist message screen?

4) I am considering to use an env. variable like CLERK_WAITLIST_ON with values of true/false and using that to have conditional code of showing soon.tsx or login.tsx.

[CG agreed with above suggestions and provided code. ... Some iterations with me asking additionally for light/dark mode support, using EXPO_PUBLIC_CLERK_WAITLIST_ON as env var.]

Me: Is EXPO_PUBLIC_CLERK_WAITLIST_ON=true

a good definition? I think it assigns string 'true' to it. In which case, it may be better to go for 1 or 0. What do u say?

CG: Excellent question — and you’re absolutely right to think carefully about this 👍

Let’s clarify exactly how Expo handles this:


🔍 What actually happens

In .env, everything is stored as a string.
So when you write:

EXPO_PUBLIC_CLERK_WAITLIST_ON=true

it is literally interpreted as:

process.env.EXPO_PUBLIC_CLERK_WAITLIST_ON === 'true'; // ✅ true
process.env.EXPO_PUBLIC_CLERK_WAITLIST_ON === true;   // ❌ false

So you always have to compare with the string 'true'.


💡 Option 1: Keep 'true' / 'false' (recommended)

✅ Most common in Expo and React ecosystems
✅ Readable and compatible with web environments
✅ Avoids confusion when reading environment files

Usage:

const waitlistOn = process.env.EXPO_PUBLIC_CLERK_WAITLIST_ON === 'true';

.env

EXPO_PUBLIC_CLERK_WAITLIST_ON=true

💡 Option 2: Use 1 / 0

✅ Slightly shorter
⚠️ But less standard — Expo and Node.js both read these as strings too,
so you’d still need to write:

const waitlistOn = process.env.EXPO_PUBLIC_CLERK_WAITLIST_ON === '1';

.env

EXPO_PUBLIC_CLERK_WAITLIST_ON=1

✅ My Recommendation

Stick with the 'true' / 'false' convention.
It’s clearer, self-documenting, and matches how most modern JavaScript projects handle boolean env flags — including official Expo examples like:

EXPO_PUBLIC_API_MOCKING=true
EXPO_PUBLIC_FEATURE_FLAG=false

So your current definition:

EXPO_PUBLIC_CLERK_WAITLIST_ON=true

✅ is perfect and standard.

Would you like me to show a one-line helper function that safely parses environment booleans (like "true", "1", "yes") into actual booleans — just for clarity and reusability?

...
[I added another env. var: EXPO_PUBLIC_WEB_SOON_URL.]

Me: The code changes seem to be working. dark/light mode, configuring app through env variables, using soon.tsx and soon.web.tsx.

On dev client in Android, Exit App only takes me to home screen. The app is still running. Minor issue but I thought of mentioning it.

CG ...

About your “Exit App” behavior:

✅ You’re right — on Android BackHandler.exitApp() (or similar logic) only closes the app’s foreground activity and returns to the home screen.
The process itself often stays alive in the background until Android decides to reclaim it (for performance and UX reasons).
This is expected behavior in modern Android (since Android 10+).

...

---------------- end chat segment extracts ----------

Below note segments have to be read in parallel to above chat segments: 
Commit: Waitlist Test - Web app shows Waitlist but Android app crashes

To test waitlist emails, the dashboard UI only provides deny to a waitlist entry and not delete.
I denied ek... but after that adding it to waitlist from UI does not add it as pending in Dashboard UI.
So I used a@b.com. that got added to waitlist which I then denied.
So I can use dummy email variations for testing this.
...

When I went back to browser on PC after adding above info. here, I saw this error:
Loading chunk 344 failed.
(timeout: https://organic-puma-48.clerk.accounts.dev/npm/@clerk/clerk-js@5.99.0/dist/framework_clerk.browser_f9679f_5.99.0.js ...
-------
Reloaded web app and added another dummy email on waiting list. App worked as expected and did not crash on wait page for at least 1 to 2 minutes.
Denied the added user on waitlist on clerk dashboard. Web app wait page continues to show - no crash.

'https://10.50.171.151:8081/soon'

Commit: Waitlist Test Android Issue Fix - Android app gives option to join waitlist on web which works
Later update: Above commit has app\soon-custui-hook.tsx.txt which is the version of code that did not work. It has this comment:
With this version:
The waitlist form is shown correctly both on web and mobile with your soon.tsx code. Add a user on web waitlist goes through with entry appearing in waitlist on clerk dashboard. But adding on Android app, shows an error: "Sign ups are currently unavailable Join the waitlist ...". Pressing OK does not add an entry to clerk dashboard waitlist.
---------------
I deleted above app\soon-custui-hook.tsx.txt file in a later commit.
---- end Later update ----
...

Commit: Waitlist Improvements - light/dark mode, env vars to configure app
----------- end note segments that have to be read in parallel to above chat segments -----
=========================

16:18, 11 October 2025

On web app, the waitlist page has an option: Already have access? Sign in
Sign in takes user to "/login" page.

We need to provide a similar feature in Android app. So soon.tsx has to be modified. [I suggested some code. ... CG provided its suggested code ... some iterations ....]

Tested the Sign in option on waitlist page on Android app on phone. First time Sign in option seemed to not work but I think I had not pressed the Google button correctly. Second time it worked though after choosing a particular gmail a/c in Google login, I had to choose lms app again in a open with kind of dialog. Then the app home page showed and I could see the My Courses page for that user correctly.

Commit: Added Sign in option to mobile Waitlist page

Code cleanup ...
...
Me: In soon.tsx, we have
        <Pressable onPress={() => router.replace('/login')}>
          <Text className="text-blue-500 text-base font-semibold">Sign in</Text>
        </Pressable>
----
Is router.push more suitable here or router.replace?

CG said that for mobile, router.replace is more suitable ... 
Summary note from CG:

Mobile vs Web UX: Sign-in Navigation from a Waitlist Page

On web apps, navigating from a waitlist page to a login page typically uses standard browser history (router.push), so users can press the Back button to return to the waitlist. This matches web user expectations.

On mobile (React Native / Expo Router), we recommend using router.replace when moving from the waitlist to the login page. This removes the waitlist page from the navigation stack, ensuring a clean, linear flow: once the user starts signing in, the waitlist page is considered “done.” Users can still revisit it by closing and reopening the app.

Why the difference?

  • Mobile UX favors simple, predictable navigation stacks and avoids confusing behavior with the hardware back button.

  • Web UX expects full history support and back navigation using the browser.

Key takeaway: Stack behavior can and should differ across platforms to provide the most natural experience for the user.

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

Finished code cleanup
Doing a test round of web and mobile for waitlist & login, using dummy email ids for waitlist.
Web app works OK for waitlist addition and login (of previously registered user).
Mobile app works OK for waitlist addition through the web page it opens on mobile. mobile app also works for login.
That finishes this segment of the tutorial video.
Commit: Code cleanup

Commenting out following line in .env:
# EXPO_PUBLIC_CLERK_WAITLIST_ON=true
results in login screen being shown (not Waitlist) on both mobile and web.
As sign in is possible through Waitlist for already registered users, for testing purposes I will retain waitlist for at least part of the next tutorial segments work.

-------
Me to CG: I should say you have been really helpful in navigating this part of the tutorial as the tutorial [has] a hacky way to implement mobile waitlist. U guided me well in implementing a nice UI showing suitable message on mobile and then redirecting user to waitlist on web on mobile. Thus user could still do the waitlist part on mobile but through the webpage on mobile browser he had been redirected to. That's a clean implementation avoiding any hacks and recognizing clearly the limitation of clerk not providing mobile waitlist facility as of now.
----
========================

Comments