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.
=====================================================================
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."
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.
...
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!
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.
============================================
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?
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.
Step: Log into the RevenueCat dashboard and click the Connect Stripe account button in your account settings.
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.
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.
--------
---------
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.
Did: npm install sonner
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
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.
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.
Purchases.configure(APIKeys.web, clerkUser!.id);
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
================================
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?
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.
Me: For Android apps, RC supports only Google Play and Amazon app store. Am I right?
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).
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:
-
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.
-
Multiple dependencies
-
These packages bring in several sub-dependencies (e.g., react-native-reanimated
, @shopify/skia-*
), so npm downloads and links all of them.
-
Network speed + npm registry latency
-
Package lock & audit
✅ 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)
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"
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
-
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:
✅ Summary:
-
The tutorial code appeared to work because:
-
iOS didn’t trigger the event until the handler was ready.
-
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:
-
Guarded onHandleCompleteLesson
-
Guarded player.replace(lesson.video)
-
Hooks are unconditional
-
Maintains tutorial-style code
-
Cross-platform safe
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:
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:
-
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.
-
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.
-
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.
-
Stack trace minification / bundling:
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:
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:
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:
-
The spurious playToEnd
event firing during mount (and now being gracefully ignored), and
-
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:
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
How it works internally:
-
Video component mounts: The native player initializes asynchronously.
-
React Native dev build: Fast Refresh / bridge may immediately trigger playback status updates — including didJustFinish: true
— before JS side has fully mounted.
-
Without delay: isMounted
flips to true
immediately, while the native player is still “settling”, so the first spurious playToEnd
still fires.
-
With a short delay (e.g., 500ms): You give the player time to:
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.
✅ So both quirks are expected:
-
Metro logs sometimes need a restart — normal in dev builds.
-
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:
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.
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:
-
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.
-
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.
-
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.
-
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:
-
Access the Collection: Navigate to your collection (e.g., user-courses
) in the Strapi Admin Panel.
-
Use the API to Fetch Records: Make a GET request to the appropriate endpoint, such as:
This request will return a list of records, each containing a documentId
field.
-
Inspect the Response: Look for the documentId
in the response data. For example:
🛠️ 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:
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:
Root Cause Analysis:
-
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.
-
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.
-
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:
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
===========================
This exact pattern caused the earlier “Android race condition” problem.
Just like you fixed before, wrap the code in an async function or mark the caller itself async:
Your UI might show a spinner or shimmer here — depending on your config.
So the “My Content” screen updates instantly when the fresh data arrives — no manual setState()
or re-navigation needed.
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.
...
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.
Comments
Post a Comment