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

Last updated on 27 Nov. 2025


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





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

=======================================
Probably 20 Nov 2025

The issue being discussed below is the lesson notes and similar description content for course, not being shown at times on Android release version app.

Me: Hmm. I think the tutorial author used a shortcut by 'use dom' and using @strapi/blocks-react-renderer which is meant for React and not React Native.
The shortcut worked for him in development build for iOS. He may not have tested it in release build in iOS.
In any case, it looks like 'use dom' and @strapi/blocks-react-renderer is not a good choice for Android release APK. 
Is my assessment correct?

CG: Short answer: Yes — your assessment is very likely correct.
What you are seeing is a classic failure mode of using @strapi/blocks-react-renderer (a web-only React package) inside a React Native Android release APK, combined with the 'use dom' escape hatch.
...

Me: Isn't there a third party component that renders Strapi blocks JSON as React Native elements

CG: Good question. I couldn’t find a well-maintained third-party component that cleanly renders Strapi Blocks (rich text JSON) natively in React Native. Here’s what I found, and some possible workarounds.

What I Found: Existing Options / Limitations

  1. @strapi/blocks-react-renderer

    • This is the official Strapi Blocks renderer. (GitHub)

    • But it’s built for React (web), not React Native. Its peer dependencies include react-dom, and its default block components assume a DOM environment. (Npm)

    • Using it in React Native with "use dom" is a hacky workaround — as you suspected — and likely brittle, especially in production / release builds.

  2. blocks-html-renderer

    • This package renders Strapi Blocks content to HTML. (Npm)

    • Not helpful directly for React Native, because React Native doesn’t support raw HTML rendering out of the box.

  3. Community / Alternative Renderers

    • I found no widely used or maintained renderer package specifically for Strapi Blocks + React Native.

    • On Strapi’s own integration page for React Native, there is no mention of a native block renderer — which suggests that Strapi itself doesn’t officially support a React Native-specific blocks-renderer. (Strapi)

    • On StackOverflow, people who want to render custom block types are reusing the code block in the React renderer as a hack. (Stack Overflow)


Conclusion & Recommendation

  • Your assessment is correct: Using @strapi/blocks-react-renderer (which expects a DOM environment) in a React Native app is likely not ideal, especially for production / release.

  • There is no well-known, battle-tested “Strapi blocks to React Native components” renderer right now.

  • Workarounds you could consider:

    1. Build a custom renderer – Write a small utility that takes the Strapi Blocks JSON and maps it to React Native components (Text, View, Image, etc.). This gives you full control and is more reliable for mobile.

    2. Convert blocks JSON to HTML on the backend (or just in your fetch layer) and then render HTML in React Native using a library like react-native-render-html.

    3. Simplify your content: If possible, restrict Strapi’s blocks to a small subset (paragraph, heading, simple list) and write a custom minimal renderer just for those.


If you like, I can search for newer / experimental libraries (2024–2025) that might do exactly this — maybe something has emerged recently. Do you want me to check?

Me: yes please

CG: Good question. I searched, and I did not find any well-known, up-to-date React Native–specific library that cleanly renders Strapi Blocks (the JSON structure) as native React Native components. Here’s a summary of what I found—and some possible approaches:


🔍 What I Found (2024–2025)

  1. No Official / Mature RN-Specific Blocks Renderer

    • The Strapi Blocks React Renderer (@strapi/blocks-react-renderer) is the official renderer for Strapi Blocks. (GitHub)

    • But it’s meant for React + DOM, not React Native. Its peer dependencies include react-dom (Npm)

    • So using it in React Native (especially in a release build) is fragile or hacky.

  2. No Community Library for RN

    • I found no third-party React Native renderer specifically designed for Strapi Blocks JSON.

    • On Strapi’s own integration page for React Native, there is no mention of a Blocks renderer. (Strapi)

    • On the Strapi community / StackOverflow, people talk about using blocks-react-renderer or building their own mapping logic. (Stack Overflow)

  3. HTML Renderer

    • There is a blocks-html-renderer package. (Npm) It can take Strapi’s Blocks JSON and produce HTML.

    • You could then render that HTML in React Native using, say, react-native-render-html or similar. This is a workaround, not a pure native block renderer.


💡 Recommendation (Given the Current Landscape)

Since there’s no ready-made, well-supported Strapi Blocks → React Native component library:

  1. Custom Block-to-Component Renderer

    • Write your own small renderer: take the Blocks JSON from Strapi, loop over each block, and map it to React Native components (Text, View, Image, etc.).

    • This gives you full control and should be more stable for release builds.

  2. HTML + HTML Renderer Workaround

    • Convert Blocks JSON → HTML (using blocks-html-renderer).

    • Then render the HTML in React Native using a library like react-native-render-html.

    • This has some overhead, but it's simpler to implement than a full custom renderer.

  3. Simplify Strapi Content

    • If possible, constrain your Strapi blocks to simpler types (paragraphs, headings, lists) so your custom mapping code is not too complex.


If you like, I can draft a small example of a custom renderer for Strapi Blocks in React Native, tailored to your app’s “lesson notes” use case. Do you want me to do that?

============
19:49, 20 November 2025

Trying out Pressable fix and checking BlocksRenderer issue possibility.
components\RichtTextContent.tsx :
...
  return (
    <View className={`flex-1 ${colorScheme === 'dark' ? 'bg-black text-white' : 'bg-white text-gray-800'}`}>
      <BlocksRenderer content={blockContent} />
      {/* Below code is to test intermittent issue of Notes being rendered as blank in Android release app.
        We need to find out if BlocksRenderer which is a React component trips up in Android release app. */}
      <Text className="mt-2 px-2 text-left text-black dark:text-white">
        {JSON.stringify(blockContent)}
      </Text>
    </View>
  );
...
--------------------

web app in dev build shows JSON below lesson notes.

Saved last created release APK as lms-v20251118-release.apk in lms-APKs folder.

Now creating new release apk
cd android
$Env:CMAKE_VERSION = '3.31.6'
.\gradlew.bat assembleRelease

BUILD SUCCESSFUL in 3m 42s
1454 actionable tasks: 92 executed, 1362 up-to-date
------------------

Installed app on phone

Strapi free tier is not responding. I went to the admin/dashboard. It shows that I am still within the free tier quota.

After many minutes, it started up. Hmm. Maybe this was an outage issue. First time I had to wait for so long.
...

Me: Now sometimes lesson notes properly rendered followed by JSON data are shown on android app. 
But sometimes neither lesson notes rich text is shown nor JSON data.

Almost always, either rich text and JSON data are shown OR neither are shown. I am saying almost always, as the first time I used it, I think I saw only rich text rendering without JSON data. But I could not reproduce that. So I am not sure.
Even when notes is not shown, the video control and bottom button are shown. It is only the notes part that is rendered by components\RichtTextContent.tsx

Then I tried the web app in development mode (just to test the Strapi cloud access). Every time it showed rendered rich text as well as JSON data.

----
I think this clearly shows that it is not BlocksRenderer alone that is the issue. 
Perhaps the main issue is the 'use dom' directive in the RichtTextContent.tsx file.

CG: Yes — your latest tests are very strong evidence that the real culprit is 'use dom' combined with React Native Android release mode, not BlocksRenderer itself.
...

[I am not able to verify whether 'use dom' is experimental in Expo SDK 52 which my project uses.]
=========

Me: HomeBlock.tsx also uses 'use dom' but this component has always worked on Android release app whenever I used the app. So is the lesson notes problem a different one?

...
------
In index.tsx for lesson:
      <View className="flex-1 p-4 min-h-[100px]">
        {hasNotes ? (
          <>
            <Text className="mt-2 text-black dark:text-white">
              {JSON.stringify(lesson.notes)}
            </Text>
            <RichtTextContent 
              colorScheme={colorScheme}
              blockContent={lesson.notes} 
            />
          </>
        ) : (
          <Text className="text-center text-gray-500 dark:text-gray-400">
            No notes available for this lesson.
          </Text>
        )}
      </View>
==========

Will create release and test.
BUILD SUCCESSFUL in 2m 16s
1454 actionable tasks: 92 executed, 1362 up-to-date

Me: For some lessons, only top JSON text is shown in lesson (so index.tsx JSON text is shown) but RichtTextContent's rich text rendering as well as JSON text are not shown. 
So now I am removing flex-1 from RichtTextContent.tsx. [CG had suggested that earlier, IFIRC.]
...
[Also added red box style suggested by CG.]

BUILD SUCCESSFUL in 1m 5s
1454 actionable tasks: 92 executed, 1362 up-to-date

Me: When rich text and JSON below is rendered, a red box is shown around it.
When rich text and JSON below are not rendered, no red box or line is seen.

CG: Great — your red-border test result confirms the root cause very clearly:

If the red border does not appear, the entire <RichtTextContent /> component is not being mounted/rendered at all.

That means:

  • It is not just BlocksRenderer failing.

  • It is not flex issue.

  • It is not JSON stringify failing.

The component itself is not present in the rendered tree for that frame.

This strongly suggests a React rendering / layout interruption, most likely caused by:


🚨 The real culprit: 'use dom' + mixed RN/DOM content + ScrollView layout timing

This combination is fragile in production builds, especially Android release, because:

1) use dom forces the component into “DOM-mode”

React Native does not natively support DOM nodes. Expo’s experimental DOM shim works only in development and is not fully reliable in Hermes + minified production builds.

This explains:

  • Works in dev mode ✔️

  • Fails intermittently in Android release ✔️

  • Web always OK ✔️

  • HomeBlock rarely fails because its DOM content is simple ✔️

  • Lesson notes fail because BlocksRenderer creates complex nested elements ✔️

2) When DOM-mode rendering fails, React silently skips mounting the component

This matches your observation:

No rich text, no JSON, no border → Entire component skipped.

🌟 This is the smoking gun.


🎯 Why the JSON shown in index.tsx does render

Because that JSON is pure <Text> inside normal RN tree.

But <RichtTextContent> runs in DOM mode → crashes internally → RN layout removes it → parent view collapses.


⭐ Conclusion

This is NOT a BlocksRenderer bug.
This is NOT a flex/layout bug.
This is NOT a data-loading bug.

This is 100% caused by:

'use dom'
+ @strapi/blocks-react-renderer (React component)
+ Android release build

This combination is unstable and produces exact symptom: intermittent component disappearance.


👍 What to do next (your best path)

Remove 'use dom' completely

This breaks BlocksRenderer because it only works in DOM mode — but you don’t want DOM mode anyway.

You need a true React Native renderer for Strapi blocks.

I will prepare a clean, simple RN-safe Blocks-to-RN renderer, but for now:


🚑 Temporary confirmation test (important)

Please do this before next release build:

Replace RichtTextContent with simple non-DOM RN code:

const RichtTextContent = ({ blockContent, colorScheme }) => {
  return (
    <View style={{ borderWidth: 2, borderColor: 'blue', padding: 12 }}>
      <Text style={{ color: colorScheme === "dark" ? "white" : "black" }}>
        RENDERING TEMP BLOCK CONTENT:
      </Text>

      {blockContent?.map((b, i) => (
        <Text key={i} style={{ marginTop: 8, color: 'green' }}>
          {JSON.stringify(b)}
        </Text>
      ))}
    </View>
  );
};

Expected result:

  • If this always renders, problem = 'use dom' + BlocksRenderer.

  • If this sometimes disappears, we investigate ScrollView layout (unlikely).

But I am 99% sure this will always render, proving the DOM renderer is the fault.


⚡ Recommendation (final)

❌ Stop using @strapi/blocks-react-renderer for Android production.

✔ Use a lightweight RN-native renderer.

I can build a proper one for your needs, mapping Strapi blocks → RN elements:

  • paragraph → <Text>

  • heading → <Text className="text-xl font-bold" />

  • list → <View><Text>* item</Text>...</View>

  • images → <Image />

  • quotes → <View><Text>...</Text></View>

Just tell me:
Do you want full rich text support or simple paragraph-only support?


If you want, I can generate a drop-in replacement:

RichtTextContentRN.tsx

that is fully RN-compatible and never requires 'use dom'.


Let me know — I can provide a polished solution.

...

Made above change.
BUILD SUCCESSFUL in 1m 10s
1454 actionable tasks: 92 executed, 1362 up-to-date


Me: The Rendering temp block content blue box and contents within were shown always in 20 to 30 views I tested.

git commit -m "Debugging Lesson content not seen at times in Android release app issue"

CG: Great — your latest test result completely narrows down the issue:

Conclusion from your “temp block content” test

Since:

  • Your manual renderer (blue box + JSON map) always renders correctly on Android release,

  • But both:

    • <BlocksRenderer />, and

    • ANY content inside the <View> containing <BlocksRenderer />
      (including the fallback JSON text and even the red border)

sometimes fail to render,

➡️ The issue is not Strapi data, not Notes, not layout, not flex, not your screen.

It is 100% caused by the combination of:

@strapi/blocks-react-renderer

and

"use dom" React DOM mode inside a React Native View

running in an Android release build.


✔ Why your blue-box test proves this decisively

Your final test removed:

  • "use dom"

  • <BlocksRenderer />

  • Tailwind/classes

  • Strapi DOM rendering logic

and replaced it with:

{blockContent?.map(...)}

This runs pure React Native only.
It rendered every time → therefore your lesson screen, navigation, notes data, and RN views are all completely fine.

The moment you reintroduce:

  • <BlocksRenderer /> (a React Web renderer, not RN)

  • "use dom" which switches the component into “React DOM mode inside RN”

…Android’s JS engine + RN layout engine cannot reliably host the DOM subtree.
Release builds aggressively optimize & strip things → intermittent failures.


✔ Why HomeBlock works but Lessons fails

You wrote:

HomeBlock also uses 'use dom' but it always works in Android release app.

This is normal and expected:

HomeBlock content:

  • Is small

  • Usually simple (headers, paragraphs)

  • Rendered once on app load

  • Inside a static screen that doesn’t re-render often

Lessons notes:

  • Much more variable content

  • Sometimes large blocks JSON

  • Block renderer is instantiated after multiple async events
    (navigation, video state, invalidations)

  • It re-renders based on:

    • lesson change

    • query caching

    • player events

DOM-inside-RN rendering with "use dom" becomes unstable when:

  1. Parent re-renders quickly

  2. Heavy DOM elements are created/destroyed

  3. Android release builds enable optimizations

  4. JSI + Fabric pipeline causes scheduling changes

So:

  • HomeBlock = simple + static → works

  • Lesson notes = dynamic + async → breaks intermittently


🎯 You now have conclusive proof:

@strapi/blocks-react-renderer + "use dom" must be removed for lesson notes.

This is exactly what your tests show.


✔ What you should do next (safe, stable options)

Option A — Convert Strapi Blocks JSON → React Native manually

Use your working blue-box method as the base.

Create a RN renderer:

function renderBlock(node) {
  switch (node.type) {
    case "paragraph":
      return <Text>{node.children.map(renderInline)}</Text>;
    case "heading":
      return <Text style={{ fontSize: 20, fontWeight: "bold" }}>{node.children.map(renderInline)}</Text>;
    // other block types...
  }
}

Stable, reliable, works on RN release.


Option B — Use a library that already exists for RN

You can use:

react-native-render-html

(very mature, fully native RN)

Flow:

  1. Convert Strapi Blocks → HTML

  2. Pass HTML → <RenderHTML />

There are known converters for Strapi blocks → HTML (I can write one for you).


Option C — Replace the whole notes renderer with Markdown

If your notes are simple text + formatting:

  1. Convert Strapi blocks → Markdown

  2. Render with:

    • react-native-markdown-display (stable)

    • or @ronradtke/react-native-markdown-display


...

If you want, I can now:

  1. Write a full RN-safe Strapi blocks → React Native component

  2. Or a blocks → HTML → RenderHTML pipeline

  3. Or a blocks → Markdown → RN markdown renderer pipeline

Just tell me which one you prefer.


Me: I think a full RN-safe Strapi blocks → React Native component may be the best option.

[CG provided it.
I made the changes. first tested with web dev. then built release]

BUILD SUCCESSFUL in 1m 9s
1454 actionable tasks: 92 executed, 1362 up-to-date

Me: Now in light mode, the lesson notes (and course overviews) are always shown on Android release app.
In dark mode, the text is not shown - only emojis are shown. I am sure that's a minor fix
Also the font size has to increased to match what earlier implmentation (BlocksRenderer) gave. Once again, I am sure that's a minor fix.

Great progress! I will take a break now and then pick it up later to improve this fix. Thanks a ton.
----

git commit -m "Progress towards fixing lesson notes intermittent blank issue"
===========================

IFIRC, the below content is of work done on 21 Nov. 2025

Me: I have modified your code to have the minimum needed for rendering my tutorial app data. Here is an example of that data for Course.description as returned as json:
[I shared the JSON with CG.]
Please note the above. Next I will upload the current source code of StrapiBlocksRenderer.tsx

CG: Sure — go ahead and upload the current StrapiBlocksRenderer.tsx and I will review it and suggest clean, minimal, reliable improvements based on your actual Strapi blocks data.

Since your blocks are very simple (just paragraphs and text nodes, with emojis), the renderer can also stay simple and does NOT need any DOM, no use dom, and no web-only renderer.

...

Me: StrapiBlocksRenderer.tsx: // ChatGPT generated code. Am commenting out most of it leaving only required parts.

...

One issue with above code is that empty paragraphs in json are not resulting in suitable gaps. Whereas Strapi's BlocksRenderer shows those gaps.

[CG gave some suggestions. There were other such issues in the rendering as I wanted it to be close to Strapi BlocksRenderer rendering on web. I am giving below only some extracts of that exchange.]

Me: For case 3 which is the case for the tutorial data,
renderBlock renders a Text component and invokes renderInline within it which renders another (nested) Text component.
Is such nested Text components OK in RN

CG: Yes — nested <Text> components are 100% valid and fully supported in React Native.

In fact, RN expects nested <Text> for inline styling. It behaves exactly like inline elements in HTML.
...

Me: Passed colorScheme into renderBlock + renderInline and applied color to Text elements. That shows the text correctly in light and dark mode. I am not modifying View backgrounds as the top-level view itself has correct background applied based on light/dark mode.
...

Me: The Strapi BlocksRenderer text looks crisp - well defined, whereas our code created text looks a little fuzzy in comparison.
...
Me: Both are using Segoe UI font. Changed line height to 22. Still it is fuzzy [CG had suggested line height 22 as suitable for font size 16. I earlier  had line height 24.]
...

Me: I am doing this comparison on web - not Android

CG: On React Native Web, slight fuzziness vs. the Strapi <BlocksRenderer> text is normal because of differences in how each renderer produces DOM text.
...

Me: Well, I tried your suggestions for crisp text like Strapi's BlockRenderer on web but could not achieve it. I will drop this for now as I guess it is OK for a tutorial.

CG: Understood — and that’s a very reasonable decision.

RN-Web text rendering differences can be surprisingly stubborn:

  • Nested <Text> vs single <Text>

  • React Native Web’s internal text split logic

  • CSS fallback differences (RNW uses fontFamily: "System" under the hood)

  • Browser subpixel aliasing vs RNW layout

  • Minor weight inconsistencies (RNW sometimes maps fontWeight: "700""bold" differently)

Strapi’s <BlocksRenderer> is browser-native HTML text, so it will always look sharper than RNW <Text> on web.

Given that this text is mostly for your own tutorial, your choice to move on is perfectly fine.

If you ever revisit this, the only robust way to get real browser-crisp text is:

Render Strapi blocks as HTML on web and as RN components on native.

But that requires a dual-path renderer.

For now, your current solution is practical and safe — good decision to proceed.

Me: Strapi’s <BlocksRenderer> is browser-native HTML text, so it will always look sharper than RNW <Text> on web.

That's a good learning takeaway for me from this effort.

...

Me: Created release app. Ran it on phone
So far, lesson text was always shown.
Light/dark mode is handled correctly.

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

11:18, 21 November 2025

After login on Android app, brief flash of:
The screen doesn't exist (in light  mode)
Back to Login
-----------
It seems to be: app\+not-found.tsx
Then user is automatically taken to home screen.

On web, after login, it shows login screen again briefly 
Then user is automatically taken to home screen.
[This issue and its fix are covered later in this post. Initially CG gave some suggestions which did not solve the problem.]
-------------

With release APK, in one app open, only the top part of bottom tabs was shown. Closing and reopening app fixed the issue. But this is very rare.

CG response: Given everything you’ve described, this is exactly what we expect when the issue is caused by Android window inset timing rather than your layout code.

Here is the realistic explanation:


Why the issue is almost fixed but might still appear rarely

On Android release builds, the safe-area insets are sometimes not ready at the instant your first screen mounts.
This causes:

  • incomplete or zero bottom inset being reported,

  • the tab bar being pushed partially off-screen,

  • usually only during cold app start,

  • and only occasionally, depending on device timing, OS load, animations, etc.

When the app is reopened:

  • the safe-area is already computed,

  • insets become correct,

  • bottom tabs display properly.

This is a very typical Android behavior.

You have already done the correct fix by:

✔ adding SafeAreaProvider at the very top
✔ wrapping your navigation area with SafeAreaView edges={['bottom','left','right']}
✔ removing the top inset to avoid the top gap
✔ not applying multiple nested SafeAreaViews (you fixed this)

Because of these fixes, the issue is 98% gone — only a rare cold-start timing race can still cause it once in a while.


🔍 Is this acceptable? Should you worry?

Short answer: This is normal and acceptable.

Even large production apps (e.g., with Expo Router + bottom tabs) occasionally see this one-off, first-launch-only visual glitch on older Android devices.

Your Samsung M21 (Android 12) is known to sometimes introduce delayed inset availability on cold boot.

As long as:

  • It happens rarely

  • Reopen fixes it

  • It never happens during navigation, only at app startup

…this is considered within normal Android behavior.


🔧 If you want to make this 100% bulletproof

Here are optional improvements (you do not need them unless you want perfection):


1️⃣ Add a small splash-screen delay before rendering layout (100ms–200ms)

This gives Android enough time to calculate insets.

Many professional apps do this silently.

// In InitialLayout or root
useEffect(() => {
  const timer = setTimeout(() => setReady(true), 150);
  return () => clearTimeout(timer);
}, []);

if (!ready) return null;

This eliminates 100% of “insets not ready” issues.


2️⃣ Force refresh safe-area insets on mount

import { useSafeAreaInsets } from 'react-native-safe-area-context';

const insets = useSafeAreaInsets();

useEffect(() => {
  // No-op: just reading causes refresh on some devices
}, [insets.bottom]);

Some devices recalc when accessed.


3️⃣ Wrap bottom tabs in their own SafeAreaView

Not needed now, but optional.


My recommendation

Based on your current behavior:

👉 You are already at the correct solution and the issue is effectively solved.
👉 One rare glitch on cold app start is okay and not worth further complexity.

If later you want to eliminate the rare case entirely, I can give you a “100% consistent insets fix” using a small splash delay.

For now?
You’re good. Nothing more to change.

Me: Great input. If I find that it is happening a little more times than acceptable, I will go for the delay option you have provided.
---------------

Me: The Start Course button worked with 1 tap in one case but needed two in another case. But even when two were needed, for the first tap as there was no brief background color change, UI/UX wise the user gets the impression that the tap did not register. On second tap, the activity indicator is shown which tells the user that tap has been registered and app is working on it. So this UI/UX is OK even if multiple taps are needed at times for 'Start course' button.

CG: Understood — and yes, that UX pattern is acceptable if the first tap shows no misleading visual feedback.

This matches exactly what you described:

👍 Case A — Worked as expected

  • First tap → ActivityIndicator appears

  • API call fires

  • Good UX

👍 Case B — First tap did nothing (no background color change)

  • No opacity/visual change → user rightly thinks “tap didn’t go through”

  • Second tap → ActivityIndicator appears → API call fires

  • UX still acceptable because there is no false feedback on tap 1

This is safe because:

  • The user never sees a misleading animation

  • They correctly infer “my tap didn’t land”

  • On the next tap, the UI responds properly

  • No confusing “button pressed but nothing happened” state

✔ This aligns perfectly with your goal:

  • Avoid confusing feedback

  • Avoid misleading button animations

  • Even if 2 taps are needed sometimes, user can understand what happened

  • UX is understandable and consistent


⭐ Why Pressable matches this UX 10× better than TouchableOpacity

TouchableOpacity gives misleading visual feedback even when onPress is not fired:

  • Opacity change on tap even if handler is swallowed

  • Looks like button was pressed

  • But ActivityIndicator never appears

  • This creates the bad UX you saw earlier

Pressable does not show any press animation by default, so:

  • If tap doesn’t register → no visual change

  • User taps again → ActivityIndicator shows

  • UX feels intentional and predictable

Exactly the pattern you described as acceptable.


🧭 Recommendation

If you are satisfied with the behavior where:

  • A tap that truly registers = ActivityIndicator

  • A tap that does NOT register = no visual change

Then Pressable is the correct component, as it guarantees this clear UX distinction.

...
============

cd android
$Env:CMAKE_VERSION = '3.31.6'
.\gradlew.bat assembleRelease
BUILD SUCCESSFUL in 3m
1454 actionable tasks: 92 executed, 1362 up-to-date
---

Ran app on phone. So far, lesson text was always shown.
Light/dark mode is handled correctly.
One minor issue is that on android, even after completing the last lesson, progress is shown as 90% at times.

git commit -m "Fixed lesson notes intermittent blank issue"

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

Me: Another issue: My app uses tanstack react-query. When I sign out (in profile tab) and then sign in as another user, stale data of old user comes into play (My Courses shows old user's My Courses). Here is the app\(app)\(authenticated)\(tabs)\profile.tsx code:
...
I had thought that once we have got redirected to login (by Clerk's signOut making Clerk's isSignedIn false and so getting redirected to /login by associated _layout.tsx file), then all react-query data will be discarded.

CG: ...

But React Query does not automatically clear cached data on sign out.

That’s why you're seeing userCourses of the old user when a new user logs in.

Let's break this down cleanly and give you the correct fix.


Why stale data appears

In your code:

useQuery({ queryKey: ['userCourses'], queryFn: () => getUserCourses(), });

React Query caches this using the key:

['userCourses']

When you sign out, React Query is not aware that “all user-specific data should be cleared”.

So the next time you sign in:

  • React Query still has cached data for ['userCourses']

  • It serves that stale data immediately (stale-while-revalidate)

  • The new getUserCourses() runs after, but stale still appears for a moment
    (or even permanently if the new user has zero courses and backend responds differently)

This is why your "My Courses" sometimes shows the previous user's courses.


🧨 Root issue

React Query caches by key, not by user identity.

You are using:

['userCourses']

But it really should be:

['userCourses', user?.id]

This way each user has isolated cached data.


Fix #1 (BEST + recommended)

Include user.id in the queryKey

Modify your queries:

const { data: userCourses } = useQuery({ queryKey: ['userCourses', user?.id], queryFn: () => getUserCourses(), enabled: !!user?.id, });

And:

const { data: completedLessons } = useQuery({ queryKey: ['userCompletedLessons', user?.id], queryFn: () => getUserCompletedLessons(), enabled: !!user?.id, });

Now:

  • When user changes, the queryKey changes

  • Old cache is never reused

  • New user gets fresh queries

React Query treats each user as a separate “namespace”.


Fix #2 — Clear all cached data on signOut()

If you want to guarantee no stale data ever survives a logout:

1. Import queryClient (from your QueryClientProvider)

import { queryClient } from '@/providers/ReactQueryClient'; // adjust your path

2. Inside signOut:

<Button title="Sign Out" onPress={async () => { await signOut(); queryClient.clear(); // clears ALL queries and mutations }} color="#FF3B30" />

This completely wipes React Query’s cache.

Strongly recommended for auth flows.

...

🔥 Why doesn't the redirect to /login fix it?

You assumed:

redirecting to /login means React Query cache should be discarded.

But React Query cache lives outside the navigation tree.

Navigation does nothing to the cache.

React Query survives across:

  • route changes

  • sign out

  • unmounts

  • remounts

  • layout switches

It clears only when you explicitly tell it to.

...

Me: React Query cache lives outside the navigation tree.
Oh! So it is quite different React useState variables then. If I got it correctly, when we navigate out of a page/screen, that page/screen's components are unmounted and so associated useState variables are discarded.

CG: Exactly — you understood it perfectly.

Let’s make the picture crystal clear:


React Query state ≠ React component state

React component state (useState / useEffect):

  • Lives inside the component

  • Exists only while the component is mounted

  • When you navigate away, the component unmounts

  • useState values are discarded

✔️ Scoped to component lifecycle
❌ Not shared
✔️ Automatically destroyed when screen unmounts


🟦 React Query state (cache):

  • Lives outside the React component tree

  • Stored globally inside QueryClient

  • Does not depend on navigation

  • Does not clear when a screen unmounts

  • Does not clear when navigating away

  • Does not clear when logging out

  • Clears only when:

    • key changes

    • cache expires due to staleTime/gcTime

    • you explicitly call queryClient.clear()

    • or queryClient.removeQueries()

✔️ Persisted
✔️ Shared across any screen
✔️ Independent of routing
❌ Does not auto-reset on logout or unmount

...

Me: Hmm. So React Query behaves more like React global state libraries like Redux

CG: Yes — that is exactly the right mental model.

React Query’s cache behaves much closer to Redux, Zustand, Jotai, Recoil than to normal React useState.

Let’s compare them clearly:


React Query vs. React useState vs. Redux (mental model)

1. React useState

  • Lives inside a component

  • Destroyed when component unmounts

  • Tied to screen/page lifecycle

  • Not shared across screens

✔️ Ephemeral, local state
✔️ Good for UI state
Not persistent


2. Redux / Zustand / Recoil / Jotai

  • Lives outside the React tree

  • Global, survives navigation

  • Data persists until explicitly reset

✔️ External data store
✔️ Persists between page loads
Must be cleared manually on logout


3. TanStack React Query

  • Also lives outside the React tree

  • Global cache inside QueryClient

  • Survives navigation

  • Survives logout

  • Fetches auto-refetch, staleTime, background refresh

  • Must be reset manually when user changes

✔️ Global store for async server state
✔️ Persists across screens like Redux
✔️ Auto-fetch / stale / refetch policies
Not auto-cleared on logout


🔥 So YES: React Query behaves more like Redux state

…except smarter because it understands:

  • stale vs fresh

  • when to revalidate

  • how to batch/sharing queries

  • dedupe requests

  • caching policies

But conceptually:

React Query Cache = Global Store of Server Data

and

Redux Store = Global Store of App UI State

Both:

  • Outlive components

  • Must be cleared/reset manually when the user context changes


🎯 Where this matters in your app

When user A signs out and user B signs in:

  • React navigation correctly moves you to /login

  • Clerk correctly sets isSignedIn = false

  • But React Query still holds cached results for:

    • ['userCourses']

    • ['userCompletedLessons']

So for a brief moment, user B sees user A’s data.


✔️ Best practice pattern

1. Include user.id in queryKey

const { user } = useUser();

useQuery({
  queryKey: ['userCourses', user?.id],
  queryFn: () => getUserCourses(),
});

2. Clear cache on logout

You must do this manually:

const { signOut } = useAuth();
const queryClient = useQueryClient();

async function handleSignOut() {
  await signOut();
  queryClient.clear();
}

or safer:

queryClient.removeQueries();  // removes all cached queries

🧠 Summary

Feature useState React Query Redux
Lives outside component
Survives navigation
Persists after logout ❌ (unless you reset manually)
Must manually clear
Good for server state

So YES — React Query behaves like Redux with extra powers
and NOT like React useState.


If you want, I can show you the exact correct logout code for your Clerk + React Query + Expo app.

Me: Got it. Thanks for the clear explantion. Now I will clear the cache on sign out - minimal change to code (this is a tutorial project)
...
===========

Tested on web. After Sign out as one user and sign in as another user, old user's data was not in play.

git commit -m "Cleared React Query cache on sign out; Attempting fix for not-found page flash on Android login"

Creating release build
BUILD SUCCESSFUL in 1m 31s
1454 actionable tasks: 92 executed, 1362 up-to-date

The login not-found page flash did not get fixed.

Created a debug build
BUILD SUCCESSFUL in 37s
1101 actionable tasks: 87 executed, 1014 up-to-date
But the file last modified time is 17-11 !

Is it possible that the debug build found that no change needs to be made to the earlier APK (even though TS code has changed)?

CG said qualified yes.
---

I decided to try it out and the debug app ran. Changed TS code got reflected in running debug app.

[Had a lot of trials to fix the not found page flash issue. The debug version made it much easier to do these trials. Eventually ...]

Me: Is there a way to log the route which triggers the not-found page

[CG was not able to give the correct way but I got the hint.] 

Me: usePathname helped me get the path that triggers not-found:
 (NOBRIDGE) LOG  Segments: ["login"] Current pathname: /login
 (NOBRIDGE) LOG  Segments: ["+not-found"] Current pathname: /sso-callback

CG: Ah — perfect! ✅

This explains exactly what’s happening:


1️⃣ Observations

Segments: ["login"] Current pathname: /login
Segments: ["+not-found"] Current pathname: /sso-callback
  • Your login screen is at /login — that’s fine

  • Immediately after SSO login, the router tries to navigate to /sso-callback

  • There is no matching file/route for /sso-callback in your project → Expo Router shows +not-found temporarily

  • After a moment, your redirect logic probably runs (router.replace('/(app)/(authenticated)/(tabs)')) → home screen appears


2️⃣ Why this happens

  • Clerk SSO automatically tries to redirect to /sso-callback after successful OAuth

  • If you don’t have a /sso-callback route, Expo Router cannot resolve it → flashes +not-found

  • This is unrelated to _layout.tsx or segmentsLoaded — it’s a routing issue caused by Clerk’s SSO redirect


3️⃣ How to fix

Option 1 — Add an sso-callback route

  • Create a file: app/sso-callback.tsx (or .tsx in the appropriate tree)

import { useEffect } from 'react';
import { useRouter } from 'expo-router';
import { useSSO } from '@clerk/clerk-expo';

const SSOCallback = () => {
  const router = useRouter();
  const { handleRedirectCallback } = useSSO();

  useEffect(() => {
    // Handle Clerk OAuth redirect
    handleRedirectCallback().then(() => {
      router.replace('/(app)/(authenticated)/(tabs)');
    });
  }, []);

  return null;
};

export default SSOCallback;
  • This ensures /sso-callback exists

  • Handles Clerk OAuth redirect properly

  • Eliminates +not-found flash completely


Option 2 — Configure Clerk to redirect directly to / or your authenticated root

  • In Clerk dashboard OAuth redirect settings, set the redirect URL to your home route:

    / (or /(app)/(authenticated)/(tabs))
    
  • Then /sso-callback is never invoked → no flash


✅ Summary

  • The +not-found flash is caused by Clerk trying to navigate to /sso-callback, not your layout code

  • Two ways to fix:

    1. Add an sso-callback route and handle redirect in code

    2. Change Clerk OAuth redirect to point directly to your home route

This is exactly why usePathname() helped you debug it — /sso-callback is the culprit.


If you want, I can provide a ready-to-drop app/sso-callback.tsx file for Expo + Clerk that handles the redirect smoothly.

Do you want me to do that?

Me: why did it not happen on web?

CG: Ah — this is a subtle but important distinction between Expo Web vs. Android (React Native) in how redirects are handled.

1️⃣ How SSO works on Web vs Native

Web

  • Clerk OAuth uses a normal browser redirect.

  • When you click “Sign in with Google/Apple,” the browser navigates to the OAuth provider and then back to your app’s URL (e.g., /login).

  • In your Expo web project, the browser URL after redirect is already handled by the router — usually /login or / — so the router never sees /sso-callback as a separate route.

  • Result: no +not-found flash, because the route actually exists (/login) and the router resolves it immediately.


Android / React Native

  • Clerk OAuth uses a WebView / Custom Tabs / SafariViewController for SSO.

  • After login, Clerk redirects to /sso-callback.

  • Your Expo Router in the React Native app sees this as a new attempted navigation path, not part of the current stack.

  • Since you don’t have an sso-callback.tsx file, the router cannot resolve it → temporarily renders +not-found.

  • Then your router.replace('/(app)/(authenticated)/(tabs)') kicks in → home screen appears.


2️⃣ Why web never shows it

  • Web redirect goes through the browser URL, which already maps to /login (or /) in your app’s routing

  • No “intermediate” /sso-callback is triggered

  • Router segments never include +not-found → no flash


3️⃣ Key takeaway

  • This is purely a platform difference:

Platform Redirect handled as Not-found page shown?
Web Browser URL /login or / ❌ No
Android / iOS In-app WebView → /sso-callback ✅ Yes, if route missing
  • So the flash only appears on native platforms when your app does not define /sso-callback.


4️⃣ Fix

  • Either create app/sso-callback.tsx in your Expo project

  • Or configure Clerk to redirect directly to / or your authenticated root, avoiding /sso-callback entirely

This is the reason you saw the flash on Android but not on web.


If you want, I can write a minimal app/sso-callback.tsx for Expo Native that handles the redirect and eliminates the flash completely.

Do you want me to do that?

Me: What is the Clerk docs recommended way of handling this in Android / RN

CG: Good question. I didn’t find a very clear “this is exactly what you should do on Android with Expo Router” example in Clerk’s docs — but I did find enough from Clerk’s custom OAuth flow guide + Expo docs + community patterns to piece together the recommended / canonical way. Below are what the Clerk docs suggest + recommended best practices, and how to apply them to your Expo (React Native) + Expo Router setup.

✅ What Clerk’s Official / Recommended Approach Is (per their Docs)

  1. Use Custom Flow for OAuth / SSO

    • Clerk’s “Custom Flows: Build a custom flow for authenticating with OAuth connections” guide shows exactly how to handle redirect-based OAuth. (Clerk)

    • There, they recommend:

      • Calling startSSOFlow (or authenticateWithRedirect on other SDKs) with a redirectUrl that points back into your app. (Clerk)

      • Creating a callback route (e.g. /sso-callback) inside your app that handles the redirect callback logic via Clerk: either by rendering Clerk’s callback component or by calling Clerk’s handleRedirectCallback / setting active session. (Clerk)

  2. Expo / React Native Specifics

    • In Clerk’s Expo Quickstart, they mention you must enable the Native API in the Clerk Dashboard for native OAuth to work properly. (Clerk)

    • For OAuth with @clerk/clerk-expo, you use useSSO() hook to start the SSO flow:

      const { startSSOFlow } = useSSO();
      const { createdSessionId, setActive } = await startSSOFlow({
        strategy: 'oauth_google',
        redirectUrl: AuthSession.makeRedirectUri(), // or custom scheme‑based URI
      });
      
    • Clerk’s blog/example for React Native / Expo shows exactly this usage. (Clerk)

    • Importantly, they call WebBrowser.maybeCompleteAuthSession() (from Expo) so that when the OAuth flow returns (deep-link), the app can “catch” the return. (Clerk)


⚠️ Common Pitfalls (from Community + StackOverflow)

  • If you don’t define a native redirect URI / deep link (like myapp://sso-callback) in Clerk Dashboard, Clerk’s callback may go to a path that your app doesn’t handle → +not-found.

  • Several devs on Reddit / StackOverflow have noted they need to configure AuthSession.makeRedirectUri({ scheme: 'your‑app-scheme', path: 'sso-callback' }) when calling startSSOFlow. (Reddit)

  • On Android, the deep link / callback path must exactly match what you’ve configured in Clerk’s OAuth settings. If there's a mismatch, the redirect fails. (Reddit)

  • Also, you should not restrict the callback route in your router or layout — the /sso-callback route must be publicly accessible so that Clerk’s redirect can land there and then you “finalize” the session.


💡 Recommended / “Canonical” Way (Based on Clerk + Expo)

Putting together Clerk’s official docs + community wisdom, here’s a recommended pattern for Expo + React Native + Expo Router:

  1. Configure Clerk Dashboard

    • In Clerk Dashboard → Native Applications, add a redirect URI like:

      yourapp://sso-callback
      
    • Also configure the same redirect URI in your OAuth provider (Google, Apple, etc.).

  2. Call startSSOFlow with correct redirectUrl

    import * as AuthSession from 'expo-auth-session';
    const { startSSOFlow } = useSSO();
    
    const onLogin = async (strategy) => {
      const { createdSessionId, setActive } = await startSSOFlow({
        strategy,
        redirectUrl: AuthSession.makeRedirectUri({
          scheme: 'yourapp',
          path: 'sso-callback',
        }),
      });
    
      if (createdSessionId) {
        await setActive({ session: createdSessionId });
      }
    };
    
  3. Create an sso-callback route in Expo Router

    • app/sso-callback.tsx (or inside (app) group)

    • In this component, use useSSO() again or useRouter() to handle Clerk’s redirect:

      import { useEffect } from 'react';
      import { useRouter } from 'expo-router';
      import { useSSO } from '@clerk/clerk-expo';
      
      export default function SSOCallback() {
        const { handleRedirectCallback } = useSSO();
        const router = useRouter();
      
        useEffect(() => {
          (async () => {
            await handleRedirectCallback();
            router.replace('/(app)/(authenticated)/(tabs)'); // or your home
          })();
        }, []);
      
        return null; // or a loader
      }
      
  4. Call WebBrowser.maybeCompleteAuthSession() on app startup

    • This ensures that when the OAuth flow brings you back to the app (Android / iOS), the session can complete.

    • Many examples (and Clerk’s own) call this as soon as the app loads / before doing SSO.


✅ Why This Aligns with Clerk’s Recommended Flow

  • It uses custom OAuth flow, which is what Clerk recommends for mobile / native. (Clerk)

  • It creates a dedicated callback route (/sso-callback), which is exactly what Clerk’s OAuth guide suggests. (Clerk)

  • It uses AuthSession.makeRedirectUri(...) to generate a deep link URI that matches what is registered in Clerk Dashboard

  • It handles the redirect by calling Clerk’s callback handler (handleRedirectCallback())

  • It is compatible with Expo Router / React Navigation — you can then router.replace to your authenticated tree


⚙️ Summary

  • Yes, Clerk expects you to handle the OAuth callback explicitly in native apps.

  • The recommended way is to use startSSOFlow with a custom redirectUrl + define a callback route + then call handleRedirectCallback.

  • Without this, Clerk’s redirect may go to a path your app doesn’t handle → +not-found.


If you like, I can check the very latest Clerk Expo docs (as of 2025) for their current recommended flow (they update often) and give you a pattern that’s “Clerk‑official‑latest”. Do you want me to do that?

Me: I don't want to invest too much time on this. It is good to know what is really causing that not found page flash. Is there a simple hack fix we can put in the not-found page to display nothing if pathname is /sso-callback. ....

CG: ...
  // Quick hack: render nothing for /sso-callback
  if (pathname === '/sso-callback') {
    return null;
  }
...

Me: Used it. Now after login, a blank screen is shown very briefly, followed by login screen which is also is very briefly shown followed by home screen. The not-found (screen) flash is gone.

...

Me: You mean the following code in _layout.tsx results in login screen being shown briefly:
  return (
    <Stack screenOptions={{ headerShown: false }}>
      <Stack.Screen name="index" />
      <Stack.Screen name="login" />
      <Stack.Screen name="(app)" />
    </Stack>
  );

CG: Exactly — that’s it. ✅

Here’s why:

  • You have this stack:

<Stack screenOptions={{ headerShown: false }}>
  <Stack.Screen name="index" />
  <Stack.Screen name="login" />
  <Stack.Screen name="(app)" />
</Stack>
  • Expo Router renders a screen based on the current segments.

  • After SSO redirect, the first segments the router sees are still technically ["login"] or ["index"] before your router.replace('/(app)/(authenticated)/(tabs)') runs.

  • Because the stack includes Stack.Screen name="login", React Native briefly renders the login screen.

  • Once your useEffect runs and router.replace completes, the stack updates to (app)/(authenticated)/(tabs) → home screen shows.


Key insight

  • This flash is not a navigation to /login, it’s a render caused by the stack containing that screen while the router’s segments are updating.

  • It’s normal behavior with Expo Router stacks — any screen in the stack whose segments match (even temporarily) will be rendered.


Optional smoothing

You can prevent the brief login flash by rendering a loader while processing the SSO callback:

if (!isLoaded || !loaded || pathname === '/sso-callback') {
  return <ActivityIndicator size="large" />;
}
  • That way, the login screen never appears during the tiny window after the redirect.


So yes — the _layout.tsx stack containing login is exactly why the login screen is shown briefly.

Me: I am getting this error now:
Warning: Error: Attempted to navigate before mounting the Root Layout component. Ensure the Root Layout component is rendering a Slot, or other navigator on the first render.

...

Me: If ActivityIndicator is attempted to be shown but there is no stack mounted in the root layout then this problem occurs. Did I get it right?

CG: Exactly — you’ve got it right. ✅
  • Expo Router requires the Root Layout to render a navigator (<Stack> or <Slot>) on first render.

  • If you return only an <ActivityIndicator> (or null) before the navigator exists, and then call router.replace() in a useEffect, Expo Router sees it as:

“You are trying to navigate before the Root Layout is mounted”

  • That’s why your warning appears.

Key point: it’s not the loader itself that’s the problem — it’s returning a loader without rendering any stack or slot in the Root Layout during that first render.

Once a <Stack> or <Slot> is mounted, showing a loader on top (even conditionally) is fine and won’t trigger the warning.

Me: But why did my addition of || pathname === '/sso-callback' trigger it?
Perhaps the other two cases of !isLoaded || !loaded  are never true at that point in the code.

CG: Exactly — that explains it. ✅

  • Before, your condition was just:

if (!isLoaded || !loaded) return <ActivityIndicator />;
  • Usually, by the time the component renders, !isLoaded || !loaded is false, so the <Stack> renders immediately, and no warning occurs.

  • When you added:

|| pathname === '/sso-callback'
  • Now, even if isLoaded and loaded are true, the condition can still be true just after the SSO redirect.

  • Returning <ActivityIndicator /> without rendering <Stack> means the Root Layout is technically not mounted.

  • Then, when your useEffect runs and tries router.replace('/(app)/(authenticated)/(tabs)'), Expo Router complains because the layout isn’t considered mounted yet.

So the warning only appeared because this new condition caused the Root Layout to render nothing (or only a loader) at a time when the router tried to navigate.


✅ Key takeaway:

  • Never return only a loader or null in Root Layout if you have router.replace() or any navigation happening immediately — always render <Stack> or <Slot> first.

  • You can still overlay a loader on top safely.

Me: So the older code of:
  if (!isLoaded || !loaded ) {
    return <ActivityIndicator size="large" />;
  }
was also incorrect. It is just that the if condition was never true and so the problem did not manifest. Did I get right?

CG: Exactly — you got it. ✅ ...

[The login screen flash continued even with some changes to above code. It was not a big issue as it was not the not-found screen. But I wanted to know about ways to fix it...]

Me: Just for my knowledge, if I were doing a professional app, then I could have a dummy stack/slot just for this case which shows nothing or the Activity Indicator. So the user will not see the Login screen flash. What do u say?

[CG gave stack based code...]

return (
  <Stack screenOptions={{ headerShown: false }}>
    {isLoadingSSO ? (
      <Stack.Screen name="sso-splash" component={SSOSplashScreen} />
    ) : (
      <>
        <Stack.Screen name="login" />
        <Stack.Screen name="(app)" />
      </>
    )}
  </Stack>
);
...

Me: Can we use a slot instead of stack while loading?

CG: Yes — you can use a <Slot /> instead of a <Stack> while loading, and it’s actually the recommended Expo Router way for a temporary “dummy” layout. ✅

Here’s how it works:


1️⃣ How <Slot /> behaves

  • <Slot /> renders whatever child routes exist at that level of the filesystem.

  • If there are no child routes for the current segments (like during /sso-callback), it renders nothing.

  • You can safely overlay a loader while <Slot /> is mounted.

  • Because <Slot /> counts as a mounted navigator, router.replace calls are safe.


2️⃣ Example usage for SSO callback / loading

import { Slot, usePathname } from 'expo-router';
import { ActivityIndicator, View } from 'react-native';

export default function RootLayout() {
  const pathname = usePathname();
  const isLoadingSSO = pathname === '/sso-callback';

  return (
    <View style={{ flex: 1 }}>
      <Slot /> {/* renders child routes if available */}
      
      {isLoadingSSO && (
        <ActivityIndicator
          size="large"
          style={{
            position: 'absolute',
            top: '50%',
            left: '50%',
            transform: [{ translateX: -25 }, { translateY: -25 }],
          }}
        />
      )}
    </View>
  );
}

3️⃣ Benefits

  • Minimal setup — no need to define a temporary stack.

  • Safe for router navigation (router.replace) because <Slot /> is always considered mounted.

  • Works well for SSO redirects, splash screens, or async pre-auth flows.

  • Prevents login screen flashes entirely, since nothing is rendered until your redirect runs.


So yes — using <Slot /> as the “dummy container” is a professional approach and cleaner than a temporary stack.

If you want, I can rewrite your _layout.tsx snippet using <Slot /> to fully eliminate the login flash during SSO callback.

...
Me:   if (!isLoaded || !loaded || pathname === '/sso-callback') {
    return (<><Slot /><ActivityIndicator size="large" /></>);
  }
Even with the above, the Login screen flash was there.
I think after sso-callback some other renders of the component are there before user is taken to home screen.

CG: Exactly — that’s what’s happening.

Even with:

if (!isLoaded || !loaded || pathname === '/sso-callback') {
  return (<><Slot /><ActivityIndicator size="large" /></>);
}
  • <Slot /> renders whatever child route exists.

  • Right after the SSO callback, the segments briefly include ["login"] (or "index") before your router.replace('/(app)/(authenticated)/(tabs)') runs.

  • React Native renders that segment for a split second → you see the login screen flash.

  • Then the router.replace completes and the home screen appears.

So the flash is not caused by your code navigating to /login, it’s a render timing issue — the router segments update faster than you can overlay a loader.


Key points

  1. The flash is harmless.

  2. The “not-found flash” issue is gone — that’s the main fix.

  3. In a professional app, you’d prevent it entirely by using a dedicated dummy stack or slot that renders nothing (or just a loader) for the SSO callback, instead of <Slot /> which still renders child routes.


If you want, I can outline a small professional pattern for SSO callback handling in Expo Router that eliminates even the tiny login flash.

[I decided that I had spent enough time on this minor issue for the tutorial app. So decided to drop any further effort to prevent login screen flash on Android app on successful login (web app already has that login screen flash on successful login).]

Fixed the not found page flash on android app through a hacky solution. Like in web app, the login screen flashes briefly but that's OK.

git commit -m "Fixed not-found page flash on Android login"
=================

19:25, 22 November 2025
Me: Please note code of app\(app)\(authenticated)\course\[slug]\[lesson]\index.tsx which is from a React Native and Expo project:
[I shared the code.]
...

Me: I am facing an intermittent issue that crops up on Android when I 'complete' a course (Beginner's course of 10 lessons). On completion, always, confetti is shown. But, at times, after the user is taken back to 'my-content' (or My Courses), the progress indicator shows 100% initially but quickly changes to 90%. The last lesson is marked as completed. The Strapi backend shows 90% as progress. At other times, it behaves correctly with my-content progress bar showing 100% and Strapi backend also showing 100%. I tried to debug it with console.log statements in the StrapiProvider function that updates the backend. But in this setup, the problem does not occur. I have more to add. But let me first send this message to you.

CG: Understood — this is a classic race-condition + double-update issue that only appears intermittently on real Android devices because of timing, navigation delays, and React Query invalidations.

Your symptoms strongly suggest:

Symptom Summary

  • On last lesson completion:

    • You always see confetti ✔️

    • user is taken to /my-content

    • Initially shows 100%

    • Then within a split second → drops to 90%

    • Strapi backend also ends up with 90%

  • Logging inside StrapiProvider prevents the bug → also indicates race/timing issue.


Root Cause (High Confidence)

Both of these functions run and write progress:

  1. useEffect (external navigation updates progress)

  2. onEndCourse / onHandleCompleteLesson (manual write of 100%)

Specifically:

(A) In your useEffect:

useEffect(() => { if (!lesson || !lessons) return; if (!externalNavigationRef.current) { externalNavigationRef.current = true; return; } const index = parseInt(lessonIndex); const progress = Math.floor(((index - 1)/lessons.length) * 100); updateUserCourseProgress(..., progress, index, true); });

On final lesson, suppose index = 10, lessons.length = 10:

((10 - 1) / 10) * 100 = 90%

So this useEffect always computes 90% for the last lesson, because it uses index - 1.

This useEffect runs:

  • When the page mounts

  • When router.replace(/my-content) runs immediately after course end

  • When the refresh / transition updates the queries

This means after your 100% update, a second update overwrites it back to 90%.


(B) Race Condition

onEndCourse() updates:

markLessonAsCompleted(..., 100);

But almost immediately, useEffect fires again because navigation is considered “external”:

externalNavigationRef.current = true; // default

So the useEffect runs after navigation and overwrites progress.

📌 The bug is intermittent because:

  • Sometimes navigation is fast → effect fires AFTER the 100% write ➤ bug

  • Sometimes navigation is slower → effect fires BEFORE the 100% write ➤ correct

This matches your actual behavior.


🧨 Why logs prevented the bug

Adding slow console.log() calls changes timing:

  • Effects delay

  • Network requests delay

  • Navigation delay

This causes the ordering to appear “correct,” so the overwrite to 90% doesn’t race against 100%.

Classic timing bug.

...

🚀 Even safer fix

Inside onEndCourse() after marking 100%:

externalNavigationRef.current = false;

So navigation that follows won’t trigger the auto-progress effect.

...

Me: Great catch! This is tutorial code. I am looking at it after quite a gap. I don't understand why the useEffect() has to invoke updateUserCourseProgress as onHandleCompleteLesson and onEndCourse invoke updateUserCourseProgress (also video played to end event calls onHandleCompleteLesson). Can you guess why the tutorial code has updateUserCourseProgress in that useEffect? [Later I saw that SG code does not have the useEffect. It is code that I added! I don't recall the reason clearly.]

CG: Yes — I can explain exactly why the tutorial code has updateUserCourseProgress() inside the useEffect, even though it seems redundant and even conflicts with the manual updates.

This pattern is very typical of tutorial codebases that try to “track progress whenever the user views a lesson.”

Here is the reasoning.


Why the tutorial uses useEffect to update progress

The useEffect:

useEffect(() => { if (!lesson || !lessons) return; if (!externalNavigationRef.current) { externalNavigationRef.current = true; return; } const index = parseInt(lessonIndex); const progress = Math.floor(((index - 1)/lessons.length) * 100); updateUserCourseProgress(...); }, [lesson, lessons, lessonIndex]);

Intended purpose of this effect in a tutorial:

➡️ Whenever the learner opens a lesson, mark progress up to that lesson as viewed.

The idea is:

  • If a user jumps to lesson 5 from outside (course list, email link, notification)

  • Or refreshes the page

  • Or re-enters the lesson via deep link

  • Or comes back after closing the app

👉 The tutorial wants to auto-progress them without requiring manual “Complete & Next” button presses.

This is very common in course apps.


📘 Typical tutorial logic

  1. Manual progress update happens when user presses "Next" / completes video.

  2. Automatic fallback update happens when the user lands on a lesson screen.

Why?

Because a student might:

  • Skip to lesson 7 directly.

  • Reload the app on the same lesson.

  • Navigate with browser back button (on web).

  • Resume a lesson from deep link.

So tutorial author wants:

“Whenever they reach lesson X, mark that the user has reached up to lesson X–1.”

...

Me: Terrific explanation. Hats off!
I think I will go for the patch of:
  // 🚫 Prevent progress overwrite on final lesson
  if (index === lessons.length) {
    return;
  }
-----
in the useEffect.

Only minor downside is that if user comes directly to lesson 10 say through the lessons drawer menu or deep link, then progress will not be updated.
I think that is an acceptable minor downside.
I don't want to go for a complete fix as this is tutorial code ...

...

Me: Now I think your suggestion of:
Inside onEndCourse() after marking 100%:
externalNavigationRef.current = false;
-----
is a better solution as even the minor issue will not be there.
...

Me: And u r suggesting adding:   externalNavigationRef.current = false;
at the beginning of onEndCourse, even before confetti stuff just to be on safe side. I mean, confetti is not going to cause the dependency change to trigger useEffect. Am I right?

CG: Correct — here’s the clear picture:

1. Confetti does not affect dependencies

Your intuition is right.
The confetti animation (confettiRef.current?.restart()) has zero connection to the useEffect dependencies. It won’t cause:

  • route pathname change

  • user progress change

  • queryClient cache change

  • local state changes that are part of the dependency array

So confetti is harmless from a dependency perspective.


2. Why I recommend setting externalNavigationRef.current = false before everything

This is purely a safety / correctness recommendation — not because confetti causes effects.

The real reason:

onEndCourse() may still trigger some chain of async state updates, directly or indirectly, such as:

  • markLessonAsCompleted might write to local state inside a hook

  • the mutation may update a React Query cache (which can hit your useEffect)

  • invalidating queries triggers re-fetch → new data → state update → useEffect triggers

So imagine this timing:

onEndCourse() starts ↓ invalidateQueries() schedules async updates ↓ Before your setTimeout fires… ↓ useEffect fires because progress changed ↓ useEffect thinks: “User progressed inside this lesson” ↓ calls updateUserCourseProgress AGAIN (wrong!) ↓ overwrites 100% progress

This is exactly the bug we want to avoid.

So we safeguard by "arming the shield" as the very first line.


❗ Why set it BEFORE confetti?

Because:

  • onEndCourse() immediately starts calling async functions

  • those async functions may start the cascade toward triggering the effect before your timeout occurs

  • you want the guard value already set when any async / state change begins

Even if the chance is small, it's safer to treat it like this:

If the function is “ending the course”, install the safety lock immediately.

...
====================

Internet is very slow.
So creating a release build
BUILD SUCCESSFUL in 4m 16s
1454 actionable tasks: 92 executed, 1362 up-to-date

Also creating a Debug build
BUILD SUCCESSFUL in 47s
1101 actionable tasks: 87 executed, 1014 up-to-date
The Debug APK date modified continues to be old date of 17-11-2025. So looks like the build did not have to modify it.

Copied release APK to phone and installed it.
Copied debug APK to emulator and installed it.

I tried: npm run android
It started to do a build. So I cancelled it. The debug and release APKs did not get deleted.
Hmmm. So expo run:android does not seem to detect that builds were done through ./gradlew command in android folder.

Used npm run start
Use a in Metro to get debug app on emulator to connect to Metro.

Internet is still down. So app is showing blank screen perhaps due to Clerk Provider getting stuck
After some (5 to 10 perhaps) minutes, Metro log showed:
 (NOBRIDGE) WARN  Clerk: Clerk has been loaded with development keys. Development instances have strict usage limits and should not be used when deploying your application to production. Learn more: https://clerk.com/docs/deployments/overview [Component Stack]
 (NOBRIDGE) WARN  [Error: ClerkJS: Network error at "https://organic-puma-48.clerk.accounts.dev/v1/client?__clerk_api_version=2025-04-10&_clerk_js_version=5.62.1&_is_native=1" - TypeError: Network request failed. Please try again.]
 (NOBRIDGE) WARN  [Error: ClerkJS: Network error at "https://organic-puma-48.clerk.accounts.dev/v1/environment?__clerk_api_version=2025-04-10&_clerk_js_version=5.62.1&_is_native=1" - TypeError: Network request failed. Please try again.]
 (NOBRIDGE) LOG  Strapi backendMode: cloud
 (NOBRIDGE) LOG  Strapi API URL: https://majestic-benefit-7638ba90d8.strapiapp.com
----

And emulator app showed login screen.
------------

Also opened release app on phone. That too is showing blank screen but with app icon in the centre (don't recall if emulator app also showed the app icon).
After some (5 to 10 perhaps) minutes, app on phone also showed App login screen.

==========
Commented console.log statement in StrapiProvider markLessonAsCompleted (as console.log may be preventing race condition issue perhaps in release build too).
Created release build.
BUILD SUCCESSFUL in 3m 7s
1454 actionable tasks: 92 executed, 1362 up-to-date

...
[Switched to old CG chat, "Button not triggering indicator" ...]

Me: 2025-11-22 23:50:14 - I find that I have to regularly double-tap on the button for it to be triggered. I want to spend some time to see if this can be improved.
Here's a related GitHub issue: Animated component issue: touchables and TextInputs inside the Animated.ScrollView are not responsive #6070, https://github.com/software-mansion/react-native-reanimated/issues/6070

Can you spot some solution in it for our case?
Also how do I check whether my project is using New Arch or not.

CG: ...

✅ 1. Is the GitHub Issue Relevant? (YES — 100% relevant)

The issue
"Animated component issue: touchables and TextInputs inside the Animated.ScrollView are not responsive #6070"
describes exactly your symptoms:

✔ Touchables inside Animated.ScrollView become:

  • unresponsive

  • require multiple taps

  • sometimes show visual feedback without firing onPress

  • worse in Android Release mode

  • triggered by Reanimated + ScrollView interaction bugs

This is exactly what you're facing.

[CG then gave a lot of suggestions ... The solution was different.]
...

⭐ My recommended practical fix order for your case:

1. Switch back to Pressable

(You already decided this → good.)

2. Add pointerEvents="none" to the Animated.Image container

This is the single biggest fix for “need multiple taps” on Android.

3. Add collapsable={false} to Pressable

4. Remove flexGrow: 1 from ScrollView

5. Add simultaneousHandlers={[]}

Do these one by one and test — Android release builds especially.

📌 3. How to check whether your project uses the New Architecture?

In Expo (important: you are using Expo):

✔ Method 1 — Simple, reliable

Run:

expo config --json | grep -i newarch

or look inside app.json / app.config.js.

If there is no setting, then:

➤ EXPO ENABLES NEW ARCH BY DEFAULT for SDK 51–52 (2024–2025)

Expo SDK 50+ uses:

  • Fabric renderer ON

  • TurboModules ON
    …unless explicitly disabled.

So your project is almost certainly New Architecture enabled.

...

Me: My app.json has "newArchEnabled": true,
...
You said: Do these one by one and test — Android release builds especially.
So I have make code changes for first, create release build, install and run on my device and test
If that does not work, make additional code change for second and do the same ...
and so on.

Did I understand you correctly? I guess trying it out in Debug APK will not be suitable OR should I try that? I don't recall whether in Debug APK I needed double tap.

[CG was ok with Debug test first.]

Me: In Debug APK, I added this code only (not made any of the other changes u suggested):
  const onStartCourse = async () => {
  console.log("onStartCourse called for course: ", course.title);
  return; // Temporary return to prevent execution during testing
----------
I wanted to see if Debug App shows this problem.
But even a light touch on the button, almost always is resulting in the console log statement showing in Metro console.
So this seems to be release app problem only and not debug app.
...

Me: Hmm. Now in release app, I don't have the adb setup for the console log to show.
Can I use alert instead? What's ur view?

CG: Yes — you can use alert(), and for quick debugging on a real device it is often the best option when adb logs aren’t available.
...

Me: I want to do this slowly as I am quite new to this stuff and find it rather overwhelming.
So I will simply use: alert("onStartCourse fired");

...
Me: Added Alert for button ... Created Release APK
app-releaseWithAlertBase.apk - Typically multiple taps or double tap is needed to show Alert.

Now I will try out the fixes. About "Add pointerEvents="none" to the Animated.Image container", the Pressable is outside the Animated.Image container view. So will Add pointerEvents="none" make any difference.

CG: Great — thanks for testing the Alert in the release APK.

Your result:

Multiple taps/double-tap needed → Alert fires only on later tap

This confirms with 100% certainty that the problem is gesture delivery in release mode, not your logic.


✔ About pointerEvents="none"

You asked:

The Pressable is outside the Animated.Image container view. So will adding pointerEvents="none" make any difference?

Most likely — YES, it can still help.

Here’s why:

🔍 1. React Native hit-testing is not based only on parent/child

Even if the Pressable is “outside” visually, your layout might still have:

  • overlapping views

  • absolutely positioned Animated components

  • transformed components

  • transparent views

  • views with elevation

  • zIndex layers

Android hit-testing can be affected even when the component is not the direct parent.

RN calculates touches based on render tree layout, not JSX nesting.

-----

git commit -m "Code cleanup; Course completion triggering UseEffect bug fix; Trials for 'Start course' button multiple taps issue"

-----
Me: 
pointerEvents: "none" on container of Animated.Image did not solve the issue.
Should I disable New architecture?

CG: Short answer: Yes — disabling New Architecture is one of the next correct tests.
And in your case, it is a high-probability fix, because this bug (touches not registering reliably inside Animated.ScrollView + Reanimated + New Architecture) is a known issue in multiple RN & Reanimated GitHub threads, including the one you linked.
...

Me: Then [as app.json change would need expo prebuild] let me try that later. Further, wouldn't there be some drawbacks of disabling new architecture?

CG: ...

Should you worry about drawbacks?

Short answer: No — disabling New Architecture in an Expo app today is generally safe and often recommended for stability.

You will not lose any major features of Expo or React Native by turning it off.


📌 Realistic drawbacks (very few)

1. You won’t use Fabric UI + TurboModules

Fabric improves:

  • Async rendering

  • Layout performance

  • Memory efficiency

But right now:

  • Many RN + Reanimated + ScrollView interaction bugs exist only in New Architecture

  • Fabric advantages are not significant for normal apps yet

  • Many production apps still run Old Architecture for stability

👉 For your use case (courses app, UI-heavy, not animation-heavy):
You will not notice any performance drop.


2. Some cutting-edge features rely on New Arch

Examples:

  • Bridgeless mode (future feature)

  • Some experimental performance improvements

But Expo does not require these at all.
They are optional and still in progress.


3. Reanimated + Fabric bugs disappear when New Arch is OFF

This is actually a benefit, not a drawback.

Right now:

  • You are hitting a known issue: touch events lost inside Animated.ScrollView

  • This issue appears only in New Architecture

  • Disabling New Architecture avoids the bug

👉 For stability, Old Architecture is better in 2025 in Expo apps.


📌 Benefits of disabling New Architecture — for your app

✔️ Much more stable touch handling on Android release builds

You eliminate:

  • double-tap requirement

  • TouchableOpacity showing tap but onPress not firing

  • random unreliability in scroll containers

✔️ Reanimated works more reliably

Reanimated 3 still has multiple GitHub issues under Fabric, especially in ScrollViews.

Your screen uses:

  • Animated.ScrollView

  • interpolated transforms

  • absolute-positioned header image

This is exactly the setup that breaks under Fabric on Android.

✔️ Your Expo app remains simpler and easier to debug

Since you're new to RN internals, sticking to Old Architecture gives a smoother experience.


📌 Who should use New Architecture today?

People making:

  • heavy Reanimated UI animation apps

  • apps needing advanced concurrency features

  • apps needing custom native modules optimized for JSI

Your app does not require any of these.


🟢 Conclusion — Practical Recommendation

For your Expo + Reanimated + ScrollView use case, disabling New Architecture is safer, simpler, and will almost certainly fix your button tap issue.

You are not losing anything that affects your app.

You are gaining stable touch handling, which is more important.

...
Me: OK. Then I will disable new architecture and try.
I should leave the pointerEvents: "none" in place isn't it

CG ...

✅ Why you should keep pointerEvents="none" even after disabling New Architecture

Even though disabling New Architecture is the major fix, the pointerEvents="none":

  • removes any possibility that the animated header image overlaps the touchable area

  • costs nothing (no performance penalty)

  • keeps the layout future-proof

  • ensures your header image never steals touches, even accidentally

It’s a safe, correct UI practice.

----
Disabled new arch in app.json
      "newArchEnabled": false,
...
npx expo prebuild 
skipped gradlew clean.

expo prebuild updated gradle.properties
newArchEnabled=false
...
but it retained my MYAPP_UPLOAD entries including passwords.

built release 
BUILD SUCCESSFUL in 7m 12s
1425 actionable tasks: 326 executed, 1099 up-to-date

Installed app on phone. That change (new arch disabling) fixed it. Even a slight touch on the button shows the alert. I tried it out perhaps 20 to 30 times. I did not have tap again any time.

Need to remove alert and then test whether rest of app is working correctly (with new arch disabled).

============
11:00, 23 November 2025

Commented alert code and so reverted to original code in 'Start course' button page.
Building release

BUILD SUCCESSFUL in 2m 39s
1423 actionable tasks: 76 executed, 1347 up-to-date
android> 

In future, for 'Start/Purchase/Continue button' sensitivity, I can use the premium course as it already has an alert. 
Earlier, I did not know whether the cause of poor 'Start/Purchase/Continue button' sensitivity was simply the button or some code executed after the onPress event had fired. So I focused on testing only the 'Start course' button of Beginner's course.

For release app on Android, from the 'Start/Purchase/Continue course' screen, switching to another app and then coming back to this app, results in restart or a bug message!

But the 'Start course' button sensitivity issue seems to be fixed now. Barring above issue rest of the functionality is working. I tried with three different users starting the Beginner's course and completing it.

The 'Start/Purchase/Continue course' screen related bug is there even with app-ReleaseBefAlert.apk. That seems to be release with New Arch (before switch to Old Arch).
Now am testing with debug APK probably with New Arch on emulator.
Got the same issue with it.
Metro shows the following error messages:
 ERROR  Your app just crashed. See the error below.
java.lang.IllegalStateException: Fragment already added: ScreenStackFragment{4454a3d} (c416260e-a2ad-4916-b323-f582e724f211 id=0x1)
  androidx.fragment.app.FragmentStore.addFragment(FragmentStore.java:93)
  ...

I then shared above issue with CG.

CG: I’ve noted the entire error and context.

This is not caused by Old/New Architecture.
This specific crash:

IllegalStateException: Fragment already added: ScreenStackFragment

is a React Navigation + Android FragmentManager issue — typically triggered when:

  • A screen is re-mounted twice,

  • Navigation state is restored incorrectly,

  • Background → foreground causes React Navigation to try to add a fragment that already exists,

  • There is a nested stack or nested navigator that re-mounts on resume.

This EXACT crash is known and documented in:

  • React Navigation Screen library issues

  • React Native Screens (react-native-screens) library issues

  • Android 13/14 activity restoration edge cases

Yes — please send the full related file (the screen that causes issues when app resumes).
Especially important if it involves:

  • Stack.Screen

  • Conditional rendering of navigators

  • Navigators inside useEffect

  • enableScreens() (or auto-enabled by Expo)

  • Deep linking

  • Anything that depends on isFocused(), useIsFocused, or route params.

Me: Note that this problem occurs only with this screen in the app. Other screens work OK. This screen uses parallax effect, IFIRC. app\(app)\(authenticated)\course\[slug]\index.tsx [Shared file's code.]

CG: ...
Short version: this is a react-native-screens / Fragment lifecycle problem (ScreenStackFragment already added) that gets triggered when this particular screen is resumed. It’s not about the Old/New Arch change you just made. Below I give a prioritized, safe troubleshooting + fix plan you can follow; each step is quick to try and progressively more invasive.
[Skipping the steps suggested.]

Me: This is a tutorial app created by somebody else. I will go with the simple fix approach enableScreens(false).

=================================
----

Noted Metro's message:
Using experimental DOM Components API. Production exports may not work as expected.
-----
Had seen it earlier but now it seems significant, given the lesson and course screen problems faced before resolution with custom StrapiBlocksRenderer.

=======

git commit -m "Switched to Old Arch as fix for Start course button multiple taps issue"

Now trying CG suggested fix for 'Start/Purchase/Continue course' screen Fragment already added related bug 
In app\_layout.tsx
import { enableScreens } from 'react-native-screens';
enableScreens(false);
----

Me: I tried the changed code in debug APK running on emulator. It did not work. Same error. I presume I don't need a new debug build for this change.
...

Me: But this issue crops up only if I switch to another app and then come back to this app

CG: Yes — that is exactly when this type of crash appears.

This symptom is very typical for duplicate navigation containers with react-native-screens enabled.

Let me explain clearly why it only crashes after switching apps, not on first load.


✅ WHY IT CRASHES ONLY AFTER APP SWITCH / RESUME

On Android, when your app goes to background:

1. Android destroys the fragment host (Activity can be partially torn down)

2. RN state is kept alive (JS context stays alive)

3. When returning, the Activity is recreated and RN remounts views

Now, imagine what happens:

✔ Expo Router automatically remounts its NavigationContainer + Stack

✔ Your custom <Stack> inside InitialLayout ALSO remounts

→ Android now sees two ScreenStackFragment instances being attached

But one is already cached/restored by FragmentManager.

💥 Crash:

Fragment already added: ScreenStackFragment

On first app load, both stacks mount cleanly at the same time.
On resume, Android tries to restore its fragment state, causing duplication.

This is why this family of crashes is documented as:

“Fragment already added errors often happen on Android during activity recreation when multiple navigation containers exist.”


✅ CONFIRMED BY BEHAVIOR

This is exactly your scenario:

❗ Works initially

❗ Crashes only after switching apps

✔ Classic duplicate-Stack + react-native-screens issue

✔ Crash only on Android (never on iOS)

Nothing else (Clerk, Strapi, Query) causes this specific crash.

This only comes from react-navigation + react-native-screens + duplicate stacks.


🚫 Trying to hide the login screen earlier triggered it

You told me earlier:

When I expanded the check, login screen flashed then error came in Android.

That also confirms:

  • When your root layout conditional changed, the order of mount/unmount changed.

  • That caused the duplicate navigation container to show up earlier.


🔥 FIX IS STILL EXACTLY THIS

Replace your <Stack> in InitialLayout with <Slot />:

❌ Your code (causes 2 stacks)

return (
  <Stack screenOptions={{ headerShown: false }}>
    <Stack.Screen name="index" />
    <Stack.Screen name="login" />
    <Stack.Screen name="(app)" />
  </Stack>
);

✅ Correct (ONLY expo-router manages stacks)

return <Slot />;

This is the fix.


💡 If you want proof

If you post your app folder tree:

app/
  _layout.tsx
  index.tsx
  login.tsx
  (app)/
    _layout.tsx
    (authenticated)/
      ...

I will show you exactly how many NavigationContainers + Stacks expo-router is already creating.


👍 TL;DR

This crash:

Fragment already added: ScreenStackFragment

✔ Happens when the app resumes
✔ Only on Android
✔ Only when react-native-screens is enabled
✔ Only when more than one navigation container or stack exists

Your duplicate stack only becomes fatal when Android restores fragment state on resume.


If you want, send me your app/ folder tree, and I’ll confirm line-by-line.

Me: but if I replace (the) stack with slot, where do I put the stack code?

CG: Great question — and here’s the important part:

❗️You do not put your <Stack> code anywhere else.

Because Expo Router already creates all the stacks automatically based on your folder structure.

Your <Stack> inside InitialLayout is not needed — and is actually harmful.


✅ Your file structure already defines all screens

From your folder root:

  • app/index.tsx

  • app/login.tsx

  • app/(app)/(authenticated)/(tabs)/…

Expo Router auto-creates:

  • A root stack

  • A nested stack for (app)

  • A nested stack inside (authenticated)

  • A tabs navigator inside (tabs)

So adding your own:

<Stack> <Stack.Screen name="index" /> <Stack.Screen name="login" /> <Stack.Screen name="(app)" /> </Stack>

…means you end up with two root stacks, which is what causes:

Fragment already added: ScreenStackFragment

Android crashes because two ScreenStackFragments get attached.


🟢 The correct structure

Your InitialLayout should be:

InitialLayout.tsx

return <Slot />;

That’s it.

Nothing else.

...
Me: How about this part:  screenOptions={{ headerShown: false }}

[CG response was not convincing in the quick look I had at it.]
--------

I think I can leave this issue as a documented issue in the tutorial for Android.

Tried clearing Metro cache - that did not solve the issue.

Reverting app\_layout.tsx to earlier code.

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

[Switched to new CG chat: Git branch change handling]

Me: I am having two experimental git branches: expa and expb. I made some changes in expa but have not committed it. I switched to expb branch. Do I have to commit expa changes before I start making changes in expb branch?

CG: ... 

✔️ Scenario you described

  • You were on expa

  • You made uncommitted changes

  • Then you switched to expb

Git allows a branch switch only if the uncommitted changes can be cleanly carried over (i.e., no conflict with files different in the target branch).

🎯 What actually happened?

When you switched to expb, Git moved your uncommitted changes along with you.

So now those uncommitted changes are sitting inside expb working directory.

Meaning:

👉 Any new changes you make now belong to expb, including the earlier uncommitted ones.

So you cannot keep expa’s changes separate anymore unless you do some cleanup.


⚠️ IMPORTANT CONSEQUENCE

Your uncommitted changes are still present after switching.
If you commit now, the commit will belong to expb, not expa.

[CG suggested git stash.]
...

Me: what does git stash do?

CG: git stash is a temporary storage area for your uncommitted changes.

Think of it like saying:

“Git, please take my uncommitted changes, hide them safely, and give me a clean working directory.”


✔️ What exactly git stash does

When you run:

git stash

Git:

  1. Saves all your uncommitted changes (modified files, deleted files, and optionally untracked files)

  2. Removes them from your working directory

  3. Leaves your directory clean, as if you just checked out the branch

So it’s like cutting your changes, putting them in a temporary box, and setting your folder back to a clean state.


✔️ Why stash is useful

You use git stash when:

  • You want to switch branches but your working changes are not ready to commit.

  • You want to experiment without losing current work.

  • You want a clean directory quickly.


✔️ Getting your stashed changes back

After switching to any branch, run:

git stash apply

This puts the stashed changes back into your working directory.

If the stash applied successfully and you don’t need the stash anymore:

git stash drop

✔️ Multiple stashes?

Yes. Each git stash creates a new entry:

git stash list

You will see:

stash@{0}: WIP on expa: <message>
stash@{1}: WIP on main: <message>

✔️ Quick mental model

  • git commit → Save changes permanently in branch history

  • git stash → Save changes temporarily outside any branch


If you want, I can give you a real example with expa and expb to show how stash avoids mixing changes between them.

Me: In your example:
git stash
git switch expb
git stash apply
-----

why do you have git stash apply after switching to expb 

CG: Great question — this is the key point to understand.

✔️ Why git stash apply comes after switching to expb

The purpose of the stash in that example was:

To temporarily remove the uncommitted changes so you can switch branches without carrying them over.

Let’s break it down.


🔍 Detailed step-by-step

Step 1: git stash (on expa)

  • Saves your uncommitted changes safely.

  • Cleans your working directory.

  • Now your working tree looks like a clean “expa”.

At this moment → your changes are not lost, just saved elsewhere.


Step 2: git switch expb

  • Because your working directory is clean, Git allows you to switch without bringing expa’s changes along.

  • Now you are on expb with a clean working directory.

So far you’ve:

✔️ Protected your expa changes
✔️ Switched to expb with no conflicts
✔️ Working directory is clean


Step 3: git stash apply (after switching to expb)

This step is optional and depends on what you want.

This step means:

“Now that I am on expb, I want to bring those changes back here.”

So you apply the stashed changes onto expb.

This is useful if:

  • You want the same work to continue on expb instead of expa.


✔️ Summary: Why apply after switching?

Scenario What you do
You want the expa changes to continue on expb stash → switch → stash apply
You want the expa changes to stay only in expa switch expa and commit → DON’T apply stash on expb

⭐ Key insight

git stash apply does not automatically go back to the original branch.
You choose where to bring the changes back.

Therefore:

You apply the stash after switching only if you want the changes in that new branch.


If you like, I can show:

  • When you should NOT run git stash apply

  • When you should use git stash pop instead of apply

  • Or a diagram comparing commit vs stash vs branch switch

Me: So stash is across git branches. In other words it is not associated with a particular git branch.

CG: Exactly — stash is not tied to any branch.

It’s global to the whole repository.


✔️ Key idea

git stash stores changes in a separate hidden area, not inside any branch.

This means:

  • You can stash changes on expa

  • Switch to expb

  • And apply the same stash there

or even:

  • Switch to main

  • Apply the stash there

  • Or drop it

  • Or keep it for later

The stash list is simply a stack of “temporary commits” stored globally in .git/refs/stash.


✔️ Stash ≠ Branch

Feature Branch Stash
Part of commit history? ✔️ Yes ❌ No
Has a name (expa, expb)? ✔️ Yes ❌ No (stash@{0}, stash@{1}, ...)
Appears in git log? ✔️ Yes ❌ No
Moves with HEAD? ✔️ Yes ❌ No
Can apply to any branch? ❌ No (commits tied) ✔️ Yes

✔️ Stash is like a “temporary clipboard” for your code

You can:

  • Cut (stash)

  • Move to any branch

  • Paste (apply)

Git does not care which branch you stashed from.


✔️ You can confirm this yourself

Run:

git stash
git switch main
git stash apply

Your expa edits will appear on main — even though you created them nowhere near main.


✔️ Conclusion

🔥 Yes — stash is completely separate from branches.

It is a repository-wide temporary storage, not linked to expa, expb, or any other branch.

If you want, I can explain:

  • difference between apply and pop

  • how to stash untracked files

  • how multiple stashes work

  • how to name stashes for clarity


Me: I want expa and expb to be completely different branches
I have some temporary work done in expa which I don't want to commit as it has not reached a logical commit stage.
But now I want to move to expb and do some work there.
So while in expa, I will do:
git stash
Then I will switch to expb:
git checkout expb
Now I will do expb work. After that has got done and committed, I will switch back to expa:
git checkout expa
Then to restore my stashed changes (in expa):
git stash apply
Followed by:
git stash drop
as I don't need that stash anymore.

Is my above understanding correct?

CG: Yes — your understanding is 100% correct, and the steps you listed are the proper, clean, safe way to keep expa and expb completely independent.
....

Me: If I mistakenly repeat a git stash command, does it create a new stash or overwrite the old stash?

CG: If you run git stash again by mistake, it does NOT overwrite the old stash.

✔️ What happens instead?

Each git stash command creates a new entry in a stack-like list:

stash@{0}  ← most recent
stash@{1}
stash@{2}
...

So running git stash twice will give you two separate stash entries, like:

git stash list

Output might look like:

stash@{0}: WIP on expa: <message>
stash@{1}: WIP on expa: <message>

✔️ Therefore:

  • No overwriting happens

  • Each stash is saved independently

  • You can apply any stash you want later using:

git stash apply stash@{1}

or drop one:

git stash drop stash@{1}

✔️ Summary

Question Answer
Does repeating git stash overwrite the previous stash? ❌ No
Does it create a new stash entry? ✔️ Yes
Is it safe if done accidentally? ✔️ Yes, you can see all stashes in git stash list

...


===================
In dummylogin branch, created initial version of app that works without login
Switched to new CG chat: Convert to custom hook

CG provided help in creating a custom hooks util.ts file based on some initial non-hook code I wrote which did not work giving hook related errors.

[I wanted to stash the work done so far in dummylogin branch and switch to main branch.]
Me: git stash is not stashing a new file.

CG: If git stash is not stashing a new file, it is almost always due to one of these three reasons:

1. The file is untracked

By default:

git stash

does NOT stash untracked files.

To stash new/untracked files, you must use:

git stash -u

or

git stash --include-untracked
...

[For some other reason, I decided to continue work on the dummylogin branch.]

[I had to find out how to force dummy user into authenticated route.]
Me: ... But anyway, the route is: /(app)/(authenticated)/(tabs) So in my dummy login, I can simply have: router.replace('/(app)/(authenticated)/(tabs)');

[CG agreed.]
 
Me: If I wanted to simulate a state change like in Clerk, then I would need to have useState variables in my dummy hooks and change their state. Am I right?

[CG said yes.]

--------

git commit -m "created initial version of app that works without login; Needs refinement"

git push -u origin dummylogin
Enumerating objects: 30, done.
Counting objects: 100% (30/30), done.
Delta compression using up to 8 threads
Compressing objects: 100% (16/16), done.
Writing objects: 100% (16/16), 2.96 KiB | 1.48 MiB/s, done.
Total 16 (delta 12), reused 0 (delta 0), pack-reused 0 (from 0)
remote: Resolving deltas: 100% (12/12), completed with 12 local objects.
remote: 
remote: Create a pull request for 'dummylogin' on GitHub by visiting:
remote:      https://github.com/ravisiyer/SG-EduApp-LMS/pull/new/dummylogin
remote: 
To https://github.com/ravisiyer/SG-EduApp-LMS.git
 * [new branch]      dummylogin -> dummylogin
branch 'dummylogin' set up to track 'origin/dummylogin'.
lms> 

Me: Following code (of util.ts) works as auto login. But I want to add a state variable dummyIsSignedIn which will be set to true/false on a new dummySignIn() function or old useRealOrDummyClerkSignOut Please advise
...

CG: Here is the clean, correct way to add a dummyIsSignedIn state variable and a dummySignIn() / dummySignOut() that simulate Clerk’s behavior.

Important Note

React hooks cannot keep shared state across different hook calls unless the state is stored in a global store, React Context, or a module-level variable with a subscriber system.

So the correct solution is:

Use React Context to simulate Clerk’s behavior

Below is the full working implementation.

[CG provided:
  • initial code for DummyAuthContext.tsx
  • code related to wrap app in DummyAuthProvider in _layout.tsx (root layout)
  • using the dummy state in my util.ts hooks
  • code to simulate login with a dummySignIn()
I had to adapt it for my needs and also fix some issues in it. I modified components\util.ts to allow for both Clerk real login as well as dummy login.]
-----

[Probably some time earlier in above chat:] git commit -m "improved dummy login with Sign out and sign in"

git commit -m "Clerk and dummy sign ins are enabled. For web, it is working"
----

Me: ... I am in dummylogin git branch. Can I use the earlier debug APK app installed on emulator with the dummylogin git branch Metro server? I think it should work. Only TS/JS changes made since last debug APK was built and installed.

CG said yes

Me: It is working with debug APK too.
tiny issue. Initialization code:
      createdAt: "1/1/2025",
is showing date correctly in Profile on web
but on Android it shows invalid date

[CG gave fix]

Me: that fixed it. works on both web and Android. Thanks.
Next I would like do a release build. I will save the app-release.apk created earlier with a modified name elsewhere. [I forgot to do that. But later I renamed the app-release.apk on phone to app-releaseLastMain.apk.]
Then I will do a release build in current dummylogin git branch. 

...

Me: I think I would like to be able to install both versions of the app at the same time on phone or emulator. So I can make suitable changes to app.json in dummylogin branch and then do an expo prebuild followed by a release build. What do u say?

CG agreed and gave app.json suggestion:
{
  "expo": {
    "name": "SG-EduApp LMS (Dummy)",
    "slug": "sg-eduapp-lms-dummy",
    "android": {
      "package": "com.ravisiyer.lmsdummy"
    },
    "ios": {
      "bundleIdentifier": "com.ravisiyer.lmsdummy"
    }
  }
}

----
First did: git commit -m "Code cleanup; Dummy login and Clerk login work on web and Android"

================
As I was making app.json changes, I saw the entry:
    "plugins": [
      [
        "expo-router",
        {
          "origin": "https://fake-devonly-lms.expo.app"
        }
      ],
----
and another such entry.

I think SG said that before production deployment, above origin has to be changed to deployed production url.
I think me not doing that, may have led to the api failing from release Android app, which we fixed with another env. variable.
================

My app.json changes:
  "expo": {
    "name": "lms-d",
    "slug": "sg-eduapp-lms-d",
    "version": "1.0.1",
    "orientation": "portrait",
    "icon": "./assets/images/icon.png",
    "scheme": "sg-ravisiyer-lms-d",
    "userInterfaceStyle": "automatic",
    "newArchEnabled": false,
    "ios": {
      "supportsTablet": true,
      "userInterfaceStyle": "automatic"
    },
    "android": {
      "adaptiveIcon": {
        "foregroundImage": "./assets/images/adaptive-icon-d.png",
        "backgroundColor": "#ffffff"
      },
      "package": "com.ravisiyer.lmsd",
      "userInterfaceStyle": "automatic"
    },
======================

Can I share same release config? [Net was too slow for CG chat. So I went ahead with same release config.]

BUILD SUCCESSFUL in 3m 27s
1423 actionable tasks: 142 executed, 1281 up-to-date
-------

Me: The release app for Dummy got installed side-by-side with main release app on my phone. I was also able to run them side-by-side on my phone.

...

Me: DL stands DummyLogin ... Profile lessons completed is not getting updated in DL app as I complete lessons. Need to check if this is DL app issue only or in Main app also. Profile screen code has following react-query ...

CG (said yes and )...

So the final code becomes:

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

Why the problem happened in DummyLogin first

  • DL app uses dummy login, so userCompletedLessons is fetched based on dummy user ID.

  • Main app uses Clerk ID → different user ID.

  • If dummy user had no completed lessons cached first, you may notice the stale-cache issue more clearly.

But logically, the missing invalidation affects both apps.

...
==========
[Later, I noted the issue in main app as well.]
==========

Me: Noted. BTW I am sharing same release config for both apps including keystore. But I am not uploading to any app store. Just direct APK installs. Will that be OK?

CG: Yes — that is perfectly OK for your use-case.

Since you are not uploading to Play Store and are only doing direct APK installs, you can safely share the same:

  • Android package signing keystore

  • Signing config

  • Build settings

between both the Main app and DummyLogin app.

✅ Why it works

Android only requires unique keystores when:

  • You are publishing multiple apps publicly on Play Store

  • You want to update an existing Play Store app

For direct APK sideloading, Android only cares about:

  1. Unique package name (applicationId)

  2. The same signing key for future updates of that same package name.

Since you changed the applicationId for the dummy app, the two apps are seen as completely unrelated apps.
Therefore:

  • They can coexist on the same phone

  • They can share the same signing key

  • No conflicts occur

This is a totally valid setup.

⚠️ Only important rule

For each app:

  • Future updates must use the same keystore that was used the first time that exact applicationId was installed on the device.

So:

  • Main app → continue using its current keystore

  • Dummy app → also continues using the same keystore (works fine)

If you reinstall dummy app with a different keystore later, Android will complain “App not installed” unless you uninstall the previous one.

🟢 Conclusion

Your approach is 100% valid for internal testing and local APK distribution.
No problems at all.

Me: at google login time, I am being asked to choose which app to use (after login process) lms or lms-d. But I have -d in lms-d scheme? Why is lms app getting confused at login time ?

[CG gave a long answer ... ]

Me: If i use a different keystore then I will not have this problem as the schemes are different?

CG: Yes — using a different keystore will prevent the problem, even if the schemes are different OR even if they are the same. [Ravi: IFIRC, when schemes were the same, even though the keystores were different for 3 apps on my phone, I used to get the open which app pop-up after login.]

Here is the key fact:


Why a different keystore fixes the issue

Android determines which app can handle the OAuth redirect based on:

  1. Intent-filter match

  2. SHA-256 certificate fingerprint (from signing keystore)

If two apps:

  • Match the same intent-filter (e.g., same deep link scheme or same fallback web URL), AND

  • Are signed with the same keystore / same SHA-256 fingerprint

→ Android thinks they are the same app family and shows you a dialog:

“Open with LMS or LMS-D?”

But if the SHA-256 fingerprints differ:

🔒 Android will treat each APK as a completely different app lineage

→ It will not try to combine them
→ It will not try to reuse the same redirect handler
→ It will not show the “Pick an app” popup
→ It will only pick the one with the matching intent-filter

So different keystore = different SHA-256 fingerprint = problem solved, even without modifying intent filters.


🔍 Why did this happen for you?

Because both apps:

  • use same keystore

  • have same Clerk OAuth redirect URL (Expo default)

  • have similar Expo auto-generated intent filters

Android detects that both apps can handle the same redirect, and because the SHA-256 matches, it assumes both are eligible → popup.

...
----------
Added: 
await queryClient.invalidateQueries({ queryKey: ['userCompletedLessons'] });
 to lesson index.tsx for both lesson completion and course completion handlers.

Stopping now.

==============
Related CG chat: Convert to custom hook
16:15, 24 November 2025 To generate a separate keystore file for the DummyLogin release, I will run:
keytool -genkeypair -v -keystore my-release-key.keystore -alias my-key-alias -keyalg RSA -keysize 2048 -validity 10000
After it is done,
 I will rename my-release-key-dum.keystore,
 move it to  android/app/ folder,
 and then update android/gradle.properties entry to:
MYAPP_UPLOAD_STORE_FILE=my-release-key-dum.keystore
Now I can do an expo prebuild followed by a gradlew release build

CG was OK with it
---
Two questions:
1) Does keytool create a different keystore file every time it is run, even if same details are specified including password in its prompts?
2) I have to manually edit android/gradle.properties MYAPP_UPLOAD_STORE_FILE to point to keystore file suitable for whether I am doing main release build or dummy login release build. Am I right? No way to automate this via a build script parameter, I presume.

CG: Here are the clear answers to both questions:

1) Does keytool create a different keystore every time?

Yes.
Even if you enter the same passwords, same alias, same name, same details, every run of keytool -genkeypair produces a completely different keystore.

Why?

Because:

  • A new RSA keypair is generated every time.

  • RSA key generation uses randomness.

  • Therefore, the final keystore file will always be different.

So you can safely generate as many keystores as you want—each one is unique.


2) Do you have to manually edit gradle.properties every time?

Yes, if you keep only one gradle.properties file.

Example:

MYAPP_UPLOAD_STORE_FILE=my-release-key.keystore

You must manually switch it to:

MYAPP_UPLOAD_STORE_FILE=my-release-key-dum.keystore

Because Gradle does not support conditional selection based on a CLI argument in a simple way without writing additional Gradle code.

✔️ Therefore:

Yes, you must manually edit gradle.properties before each build (main vs dummy).


⭐ Optional (Clean) Automation Ideas

If you want to avoid manual editing, here are safe options:


Option A — Two separate Android folders

/android-main
/android-dummy

Each has its own gradle.properties and its own keystore.

Then:

cd android-main && ./gradlew assembleRelease
cd android-dummy && ./gradlew assembleRelease

Cleanest separation. No cross-contamination.


Option B — Use environment variables

Modify gradle.properties like:

MYAPP_UPLOAD_STORE_FILE=${KEYSTORE_FILE}
MYAPP_UPLOAD_KEY_ALIAS=${KEY_ALIAS}

Then build with:

KEYSTORE_FILE=my-release-key.keystore \
KEY_ALIAS=my-main-key \
./gradlew assembleRelease

Or for Dummy:

KEYSTORE_FILE=my-release-key-dum.keystore \
KEY_ALIAS=my-dum-key \
./gradlew assembleRelease

But this requires a small modification inside app/build.gradle to respect env variables.


Expo (Classic)

You cannot parametrize keystore choice during expo prebuild or universal build.
But you are already doing manual Android builds, so you’re safe.


✅ Conclusion

✔️ Answer 1: Yes, a new keystore is always different.

✔️ Answer 2: Yes, you must manually switch keystore in gradle.properties unless you customize your build system.

If you want, I can guide you through setting up Option B: environment-based keystore switching, which avoids needing multiple android folders.

[Customization seems non-trivial]
-----------

Created dum release store, made gradle.properties change.
did expo prebuild
creating release (dummylogin)
BUILD SUCCESSFUL in 2m 32s
1423 actionable tasks: 85 executed, 1338 up-to-date

Renamed file to app-release-dum.apk
[I had a similar renamed file earlier in this SG-EduApp\lms\android\app\build\outputs\apk\release folder but that seems to have got deleted by the build. Does renaming release file make subsequent release build longer. I don't think so. The key folder for reusing build artifacts seems to be build\intermediates which is 3.3 GB now whereas outputs is only 383 MB (has both release and debug APKs).]

CG: ...

1) Does renaming the APK in outputs make future builds slower?

No — renaming files inside android/app/build/outputs/apk/... has zero impact on build time.

Reason:

  • Gradle never reads the output folder to decide what to rebuild.

  • Gradle uses intermediate compiled artifacts (in build/intermediates/) for incremental builds.

  • outputs/ is write-only final artifacts; Gradle does not consume them.

So renaming:

app-release.apk → app-release-dum.apk

does not slow down builds.


2) Why did your previously renamed APK disappear?

Because:

  • The outputs/ folder is cleared and recreated on every assembleRelease or bundleRelease.

  • Renamed files are not tracked by Gradle → Gradle deletes the entire output folder before writing new APKs.

So your previous renamed APK got removed during:

./gradlew assembleRelease

This is expected behavior.

✔️ So, if you need to keep renamed APKs, store them outside the build folder, e.g.:

SG-EduApp/build-archive/ app-release-main-2025-11-24.apk app-release-dum-2025-11-24.apk

This is strongly recommended.


3) Which folder actually affects build times?

🏗️ build/intermediates/

This folder stores cached compilations, processed resources, dex files, etc.

Yours is:

3.3 GB

This is normal for a React Native Android build.
This is the folder that reduces build time on subsequent releases.

If you delete it or run:

./gradlew clean

then the next release build will be much slower (full rebuild).

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

I moved (not copied) the dum release file to SG-EduApp\lms\lms-APKs folder. Based on above that should not delay future builds.

CG: Correct — moving the APK out of the outputs folder will not delay future builds.
----

Installed dummylogin release on phone (it already has main release)
Ran it (lms-d). It seems to be working OK.
Ran lms app. At login time, it once again asked me to choose which app to use to open - lms or lms-d. So new keystore file did not fix it.

[CG response led me to check intent-filter as given below.]

android\app\src\main\AndroidManifest.xml has:
      <intent-filter>
        <action android:name="android.intent.action.MAIN"/>
        <category android:name="android.intent.category.LAUNCHER"/>
      </intent-filter>
      <intent-filter>
        <action android:name="android.intent.action.VIEW"/>
        <category android:name="android.intent.category.DEFAULT"/>
        <category android:name="android.intent.category.BROWSABLE"/>
        <data android:scheme="sg-ravisiyer-lms"/>
        <data android:scheme="com.ravisiyer.lms"/>
        <data android:scheme="exp+sg-eduapp-lms"/>
        <data android:scheme="sg-ravisiyer-lms-d"/>
        <data android:scheme="com.ravisiyer.lmsd"/>
        <data android:scheme="exp+sg-eduapp-lms-d"/>
      </intent-filter>
----

[The old entries are still there. That's seems to be the cause
Perhaps expo prebuild with clean will fix it.]
[I confirmed that app.json for this dummylogin branch did not have the old scheme of sg-ravisiyer-lms]

...
Me: As a test run, let me manually modify the manifest.xml to remove the old scheme entries, and then run gradlew for release.

CG was OK with it

Removed following from the file:
        <data android:scheme="sg-ravisiyer-lms"/>
        <data android:scheme="com.ravisiyer.lms"/>
        <data android:scheme="exp+sg-eduapp-lms"/>
-----
Ran:  ./gradlew assembleRelease
BUILD SUCCESSFUL in 23s
1423 actionable tasks: 80 executed, 1343 up-to-date

Copied file to phone and installed it.
Opened lms-d and used it. While it was open, opened lms as well.
In lms: 
Logged out of auto-logged in user
Logged in.
This time, after login, I was shown the open which app popup but instead was taken directly to home screen.

So we seem to have the solution.
-----------

CG: Excellent — that confirms the root cause 100%:

✅ The real app’s scheme entries left behind in the manifest caused Android to think both apps could handle the login callback URL → hence the “Open with lms or lms-d?” popup.

🎉 By removing the old schemes from AndroidManifest.xml, you fixed it.

And the behavior you observed:

after login, no chooser popup — it went directly to the home screen

…is exactly what should happen when only one app owns that deep-link scheme.

----------

So expo prebuild --clean
is needed for proper scheme in release build.

==========
DummyLogin branch:
git commit -m "App.json modified to use Dummy login name, scheme; Fixed profile lesson data update issue"
---------------

Me: Please modify login.tsx code given below to show a suitable alert/popup on Dummy login that: this feature is added to original tutorial which is not specifically designed to handle simultaneous usage with same user login. So app behaviour may be a little odd if multiple users are using this Dummy login at the same time. Note that Dummy login uses a single predefined Dummy Login Id. Here's login.tsx code:
...

[CG provided code. Some iterations to improve message and also to work on both web and mobile.]

Added Dummy Login Alert/confirm message
Works on web.
Built debug apk
BUILD SUCCESSFUL in 4m 52s
1072 actionable tasks: 215 executed, 857 up-to-date
-----------------

Installed debug version on Android (Needed uninstall of lms app to create enough space as first install attempt failed; I must increase emu size to say 10 GB)
Alert message is showing correctly with enough room on emulator. Tested dummy login as well as Google SSO login.

Creating release apk (without expo prebuild stuff)
BUILD SUCCESSFUL in 1m 33s
1423 actionable tasks: 76 executed, 1347 up-to-date

Installed app on phone. Works OK. Alert message is showing correctly with enough room on phone. Tested dummy login as well as Google SSO login.
Also checked that lms app Google login does not result in open with app popup.

git commit -m "Added DummyLogin Alert/confirm message"
--------
Saw there was no code addition in clerkguestlogin branch. Now we don't need it as Dummy Login is good enough for our needs.
lms> git checkout main            
Switched to branch 'main'
Your branch is up to date with 'origin/main'.
lms> git branch -d clerkguestlogin
Deleted branch clerkguestlogin (was 2840c62).

Saved CG suggested Guest Login code as GuestLogin-login.tsx.txt in GuestLogin folder, for any future needs/reference.
-------------------
In CG chat: Git branch change handling
21:13, 24 November 2025 

Now I need to selectively merge lesson index code and perhaps profile code from dummylogin branch to main branch.

In VSCode:
Opened lessons index.tsx while in main branch
Right-Clicked file tab -> Open Changes -> Open Changes with Branch or Tag -> Selected
 DummyLogin branch
Got shown index.tsx(dummylogin) <--> index.tsx (like typical Compare with Selected file)
Picked the changes I wanted (all of the two changes, in this case) from left to right.
Saved file. That's it for this file changes.

[Later I came to know that the above Open Changes menu entry/command was shown because, IFIRC, I had enabled GitLens extension and then disabled it but not refreshed VSCode Extensions stuff to reflect that. If GitLens extension is disabled (and, if required, VSCode Extensions stuff refreshed to reflect that disabled state) then Open Changes menu entry is not shown.]
-------------
To compare main branch with dummylogin branch (as of now), I enabled GitLens extn.
Clicked on an icon to list branches
Right-clicked one branch and chose Select for Compare or similar
Right-clicked the other branch and chose Compare with selected or similar
Now I was shown a list of (10) files with changes. Clicking on a file showed two tabs with file contents in the two branches.
But it shows the contents as of last commit and not as of saved.

I went through all the changes. None had to be merged in main (I had already merged lesson index.tsx changes).

-------------
Me: In main branch, I have app.json with the main app scheme and name (lms).
But the android folder has some key files related to last builds done in dummylogin branch.
So now to prevent that stuff from interfering with builds of lms app on main branch, I need to do expo prebuild clean and then do the debug and release builds.
Am I right?

CG: Yes — you are absolutely right.

If your android/ folder currently contains files that were last generated in the dummylogin branch, then those build artifacts will definitely interfere with building the main (lms) app.

This happens because:

  • The android/ folder is NOT reset automatically when switching branches

  • It still contains whatever was generated during the last expo prebuild

  • That includes:

    • package name

    • scheme

    • app name

    • intent filters

    • native configs

    • AndroidManifest.xml entries

    • build.gradle configs

    • google-services.json layout

    • splash/drawables

    • anything produced by config plugins

So if dummylogin had a different scheme, bundle identifier, app name, or config plugins, those stale native files will persist and cause wrong builds.


Correct procedure (your understanding is perfect)

Step 1: Clean the existing native folders

This removes the stale dummylogin native output:

expo prebuild --clean

(or older syntax)

npx expo prebuild --clean

This deletes:

  • android/

  • ios/

and regenerates them from app.json of the current branch (main branch → lms)

Exactly what you want.


Step 2: Run Android debug build again

npx expo run:android

This creates a fresh debug build purely from main branch’s app.json.


Step 3: For release APK

cd android
./gradlew assembleRelease

or from project root:

npx expo run:android --variant release

This ensures the signing, package name, scheme, etc., all match main branch’s lms app.


⭐ Why this is important

If you don’t clean, you risk:

  • Wrong app name showing

  • Wrong splash screen

  • Wrong package name (install conflicts!)

  • Wrong scheme (affects deep links, auth redirect URLs)

  • Stale google-services.json config

  • Manifest conflicts

  • Gradle errors due to leftover config plugin changes

So doing expo prebuild --clean is absolutely the correct move.


🎯 Final answer

✔️ Yes, you must run expo prebuild --clean in main branch

✔️ Then rebuild debug and release

✔️ This ensures no dummylogin native artifacts pollute your lms build


If you want, I can also tell you how to:

  • Quickly verify android app id/package name after the clean

  • Compare dummylogin and main AndroidManifest.xml

  • Automate this process with a script so you never forget steps again


Me: I think I will delete android folder before I do expo prebuild --clean. That is being very safe and I will know for sure that no dummylogin branch app build artifacts are there to interfere with this main branch app builds.

CG: ... 
👍 Final Answer

Yes — you are right.
Deleting the android/ folder before running expo prebuild --clean is the safest way to ensure that no old dummylogin native artifacts interfere with main-branch lms app builds.
------------------------

Clean APK debug and release build
First debug build but release config is also set up now.

1) Delete android folder
2) npx expo prebuild --clean --platform android

4) Copy my-release-key.keystore file to android/app/my-release-key.keystore
5) Edit android/gradle.properties and add (including changing password suitably):
MYAPP_UPLOAD_STORE_FILE=my-release-key.keystore
MYAPP_UPLOAD_KEY_ALIAS=my-key-alias
MYAPP_UPLOAD_STORE_PASSWORD=your_keystore_password
MYAPP_UPLOAD_KEY_PASSWORD=your_key_password
----
6)  Edit android/app/build.gradle  
     In existing signingConfigs block which should already have debug block, add release block:
signingConfigs {
debug { ... }
release {
storeFile file(MYAPP_UPLOAD_STORE_FILE)
storePassword MYAPP_UPLOAD_STORE_PASSWORD
keyAlias MYAPP_UPLOAD_KEY_ALIAS
keyPassword MYAPP_UPLOAD_KEY_PASSWORD
}
}
     In buildTypes release block delete old signingConfig line and replace it with
        signingConfig signingConfigs.release
        as shown below:
buildTypes {
        release {
      signingConfig signingConfigs.release
      ...
}
}
        ---------
7) cd android
8) .\gradlew.bat clean
BUILD SUCCESSFUL in 51s
59 actionable tasks: 44 executed, 15 up-to-date
9) $Env:CMAKE_VERSION = '3.31.6'
10) .\gradlew.bat assembleDebug
----------------
BUILD SUCCESSFUL in 8m 14s
1070 actionable tasks: 1055 executed, 15 up-to-date
android> 

=========================
Me: .\gradlew.bat clean
BUILD SUCCESSFUL in 51s
59 actionable tasks: 44 executed, 15 up-to-date

This is on a clean build. Why did it take so long. What did it have to clean?

CG: Even on a freshly generated android/ folder, .\gradlew clean can still take 20–60 seconds.

This is normal behavior. Here’s why:


Why gradlew clean takes ~50 seconds even on a clean build

### 1. “Clean” does not only delete build folders

Even when there are no old build artifacts, Gradle still:

  • Scans all modules (app + dependencies)

  • Evaluates tasks

  • Reads build configuration

  • Verifies dependencies

  • Prepares the task graph

  • Checks if build/ directories exist for each module

  • Deletes folders even if they are empty

Gradle must walk through every task and mark it as “cleaned”.

This takes time even when there is nothing to delete.


### 2. RN + Expo prebuild creates many Gradle modules

Expo prebuild generates:

  • expo-modules-core

  • expo-file-system

  • expo-splash-screen

  • expo-updates

  • react-native-gradle-plugin

  • dozens of other native modules

Even if they have no build outputs yet, Gradle still needs to:

  • Load each module

  • Evaluate build scripts

  • Register clean tasks

  • Execute the clean actions

This adds up to many tasks, even if everything is empty.


### 3. Windows + Gradle = slower file operations

On Windows:

  • File system deletes are slower

  • Antivirus scans the deletion operations

  • Disk I/O is slower for large folder scans

So the clean task takes longer than on macOS/Linux.


### 4. Clean forces Gradle to NOT use caches

Even if nothing is built, Gradle still:

  • Deletes configuration caches

  • Avoids incremental build caches

  • Re-evaluates full build scripts

This evaluation itself takes time.


### 5. Debug vs Release doesn’t matter — clean always evaluates all tasks

The message:

59 actionable tasks: 44 executed, 15 up-to-date

Means:

  • 44 tasks were run during clean (even without artifacts)

  • 15 tasks were recognized as already clean

The executed ones take time.


🧠 In short

Even on a freshly prebuilt project:

  • Many tasks still need to be evaluated

  • Gradle prepares and runs “clean” tasks for all modules

  • Windows file system + antivirus slows it down

  • So 40–60 seconds is normal for a clean in an Expo/RN project on Windows


👍 Recommendation

You almost never need to run:

gradlew clean

Unless:

  • Gradle behaves inconsistently

  • Dependencies changed at native level

  • Build folder got corrupted

  • Switching branches with different native code

Doing a clean once after prebuild is fine — but it will always be slow.


If you want, I can explain exactly what the 59 tasks are doing during clean.

-----
Me: On Web with dev build, I see following warning on console:
installHook.js:1 props.pointerEvents is deprecated. Use style.pointerEvents Error Component Stack
    at withDevTools(:8081/ErrorOverlay) (<anonymous>)
overrideMethod @ installHook.js:1
-----

Is that caused by this code in one file in the project:
      <View className="relative" style={{ height: HEADER_HEIGHT, pointerEvents: "none" }}>
        <Animated.Image
 
CG said yes ...
 
Me: Changed the code to:
      <View pointerEvents="none" className="relative" style={{ height: HEADER_HEIGHT}}>
        <Animated.Image

CG ok'd it.

Me: I changed the code, reloaded the web app. Still I am getting:
installHook.js:1 props.pointerEvents is deprecated. Use style.pointerEvents Error Component Stack
    at withDevTools(:8081/ErrorOverlay) (<anonymous>)

CG: This warning does NOT mean your code is still wrong — it almost always comes from some other internal component, not your code.

Here’s how to confirm that clearly and why you still see it:


✅ 1. Your updated code is correct

Using:

<View pointerEvents="none" ...>

is the recommended RNW usage.
This cannot trigger a warning about “props.pointerEvents is deprecated” because you are no longer passing it inside style.

So the warning is coming from some other component in the component tree — possibly:

  • a library component

  • a React Native Web internal wrapper

  • an Expo Router wrapper

  • ErrorOverlay (likely, since the stack shows it)


✅ 2. The key clue: the stack trace

You said:

installHook.js:1 props.pointerEvents is deprecated. Use style.pointerEvents Error Component Stack at withDevTools(:8081/ErrorOverlay)

This means:

  • The warning originates inside Expo DevTools / ErrorOverlay, NOT your app code.

  • ErrorOverlay internally still uses props.pointerEvents.

This is a known Expo Web dev overlay issue, and it cannot be removed by fixing your code.

...
-------------

Me: Tested lms debug for one user and many lessons on both emu and web. It worked as expected.

Now I have to do a release build. As it was a clean build and I have already set up release config properly in android gradle files (and have release keystore file in correct folder), I think I need to simply run: .\gradlew.bat assembleRelease

Am I right?

CG said yes
------

android\app\src\main\AndroidManifest.xml has:
      <intent-filter>
        <action android:name="android.intent.action.VIEW"/>
        <category android:name="android.intent.category.DEFAULT"/>
        <category android:name="android.intent.category.BROWSABLE"/>
        <data android:scheme="sg-ravisiyer-lms"/>
        <data android:scheme="com.ravisiyer.lms"/>
        <data android:scheme="exp+sg-eduapp-lms"/>
      </intent-filter>
-----

[In main branch]
git commit -m "Merged profile lessons update fix from dummylogin to main branch; cleanup; ready for release build"

For release build
1) cd android
$Env:CMAKE_VERSION = '3.31.6' is not needed as it is already set.
2) .\gradlew.bat assembleRelease
BUILD SUCCESSFUL in 19m 56s
1423 actionable tasks: 1373 executed, 50 up-to-date

Copied apk to phone and installed it. Logged in as a user, navigated through some lessons and other screens. Logged out and logged in as another user.
Started Beginner's course (as new course initiated with 'Start course' button). Played video on 1st lesson. Allowed it to auto play through course. An alarm halted it but I manually continued it after alarm dismissal. Barring that, there was no user action on the app. It auto played through all the lessons, showed confetti at end of last lesson and then showed my-content/My Courses screen with progress bar showing 100%.
The app seems to be working as expected.

Renamed file as lms-v20251124-release.apk (114 MB)
Uploaded to public Google Drive folder. (jio upload and so 2 to 3 Mbps)
===========

Me: .. Now I want to do eas web deployment. Instead of deploying directly as production, I would like to make it a dev deployment, test it out and if OK, upgrade that deployment to production. Does eas support that?

CG : Yes — EAS supports exactly what you want.

You can publish a development (preview) deployment, test it, and then promote it to production without rebuilding.

Expo calls these channels and rollouts.


✅ How Expo/EAS Deployment Works

Expo has 3 important concepts:

  1. Build → Artifacts
    (APK / IPA / Web bundle)

  2. Update → Channels
    (dev, staging, production)

  3. Rollout / Promote → Move one update to another channel

So you can do:

dev deployment  →   test   →   promote to production

This is fully supported.


✅ How to do a Dev (Preview) Web Deployment

From your project root:

eas update --branch dev --message "Web preview deploy"

This will:

  • Build the web bundle

  • Upload it to Expo hosting

  • Assign it to the dev branch/channel

You can then open the dev URL that Expo prints.


✅ After testing → Promote to Production

Expo supports promoting an update from one branch to another without rebuilding:

eas update:promote --from dev --to production

This takes the exact tested bundle and moves it to production.

No need to run eas update again.


💡 Alternate Method: Use “Channels” directly

If you prefer channel-based names:

Dev deploy:

eas update --channel dev --message "test web build"

Promote:

eas update:promote --from dev --to production

Both work — Expo treats branch = channel unless configured otherwise.


🟢 Summary

Yes, EAS fully supports dev → test → promote to prod flow:

Step Command Result
Deploy dev version eas update --branch dev Dev-only deployment
Test on web Use Expo-provided dev URL Verify everything
Promote to prod eas update:promote --from dev --to production Instantly live in production

If you want, tell me your existing EAS channels (run: eas channel:list) and I’ll guide you exactly which commands to run.

Me: I will use the web interface to upgrade a dev deployment to production (no channels used).

But the above is for the lms app.

Later I will want to deploy the lms-d (dummylogin) web app too. This time, I want to use channel so that it will be a different url from regular production url - that's what you had advised me earlier.

CG agreed.

---------

Currently .env has EXPO_PUBLIC_DUMMY_LOGIN_ID=User_Dummy_Not_ClerkId (used by dummylogin branch code)
But I will be using package.json script which will copy .env.cloud.fs as .env before doing the expo web export 
.env.cloud.fs does not have any such env variable.
Neither does eas hosting site have it.
[Later for dummylogin branch I should create a .env.cloud.dlfs file having EXPO_PUBLIC_DUMMY_LOGIN_ID=User_Dummy_Not_ClerkId]

Ran:
eas whoami
npm run export-web
eas deploy
🎉 Your deployment is ready

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

Logged in to above deployment and did some minimal navigation. It worked as expected 
----
Next I should do thorough testing of the release app installed on phone and above deployed web app.
===================

15:50, 25 November 2025

Deleted Clerk and Strapi own users
Deleted all user-course and progress Strapi rows.
So now on clean slate.

Testing lms app on phone with dG user

Went through all of beginner's course.
Full screen and rotate to landscape mode played current video correctly. But autoplay of next video showed blank video with some audio heard IFIRC. Had to restart app. Relatively minor issue - so will record it on app blog post as tutorial unresolved issue. Perhaps a future patch to try out would be to disable autoplay on Android. Note that on web autoplay is either not enabled or does not work (so fullscreen ends automatically on video end and and shows user the lesson screen where he can move forward to next lesson. I tried that later and it worked.).

Other than above full screen landscape issue, app worked as expected.
Clerk user got created correctly (xy)
Strapi user got created correctly.
Stapi user-courses and progress entries are as expected.

Now testing web app: https://sg-eduapp-lms--3vd4lap1j6.expo.app/ with rG user
Went through all of beginner's course.
Fullscreen ends automatically on video end and and shows user the lesson screen where he can move forward to next lesson. I tried it and app worked as expected. (No need to restart app).
Dark/Light mode works.
Clerk user got created correctly (xz)
Strapi user got created correctly.
Stapi user-courses and progress entries are as expected.

Now testing web app: https://sg-eduapp-lms--3vd4lap1j6.expo.app/ on Chrome mobile browser premium course purchase with eG user

After login, the login screen was shown for noticeable amount of seconds making me wonder whether I need to login again. Then I was shown home screen. I think this is a minor issue; will document it; future patch may be to use Activity Indicator on Login screen while this loading process is going on.

Purchased (dummy/sandbox purchase) premium Elevate course successfully.
Saw first lesson of course on mobile browser. It worked as expected.
Clerk user got created correctly (y1)
Strapi user, user-course and progress entries are as expected.
Logged out as eG user from mobile web and closed web browser.

Opened Android app.
Logged in as eG user.
Switched to Light mode from Dark.
Continued the other 4 lessons of Elevate course (using autoplay) and completed course.
It worked as expected.
Strapi user-course and progress entries are as expected.

That completes the tests.
Both Android app and web app are ready for release.

On expo.dev, I promoted the https://sg-eduapp-lms--3vd4lap1j6.expo.app/ deployment to production. So now https://sg-eduapp-lms.expo.app/ should go to that deployment.

===================
17:18, 25 November 2025


Report is OK.
-----------

Uninstalled lms app from phone. Deleted app-release.apk from phone to be on safe side.

Downloaded latest Android app from blog post link
Installed app on phone and used it. 
It worked ok.

Also bought premium course for another user on mobile browser using https://sg-eduapp-lms.expo.app/ web app. Then switched to Android app and played one lesson of premium course . It worked ok.

Deleted Clerk my users
Deleted Strapi my users, user-course and progress entries.
Now on clean slate

On Android lms app, logged in as dG user, started beginner's course, played one lesson. Signed out. All OK.
On PC web https://sg-eduapp-lms.expo.app/, logged in as rG user, started beginner's course, directly clicked on first lesson in left drawer, the lesson showed, but the video did not show nor play! Refreshed page, this time the video showed and played properly. Clicked 3rd lesson on drawer. This time the video showed and played OK. Signed out. All OK barring that need to refresh page for one lesson video play, which I think is a minor issue.

So released lms Android app and deployed web app are tested and work OK.
-------------

[In CG chat: Android production bug fix]
Me: 2025-11-25 23:13:08 This relates to the EXPO_PUBLIC_WEB_API_URL related fix we discussed earlier in this chat and which I implemented in my app. If you do not recall it, I can tell you about it.

Before I continue, let me see if you recall it from this chat.
...

Me: Great you recall it correctly.
As I was making app.json changes for some other work, I saw the entry: 
    "plugins": [
      [
        "expo-router",
        {
          "origin": "https://fake-devonly-lms.expo.app"
        }
      ],
----
and another such entry.

I think the tutorial author (SG) said that before production deployment, above origin has to be changed to deployed production url. 
I think me not doing that, may have led to the api failing from release Android app, which we fixed with another env. variable.
What's ur view? 

CG: Yes — your suspicion is correct, and this is exactly the kind of issue that the origin field in the expo-router plugin can cause.

Let me give you a clean and accurate explanation:


✅ What expo-routerorigin actually does

In app.json, this section:

"plugins": [
  [
    "expo-router",
    {
      "origin": "https://fake-devonly-lms.expo.app"
    }
  ]
]

sets the base URL used by Expo Router during deep-link resolution.

This affects:

  • deep link redirects

  • authentication callbacks

  • router navigation when opening the app from a link

  • web → native WebView transitions

  • certain router-generated URLs

💡 It does NOT affect normal fetch() API calls.


❗ So why can using a wrong origin break your released Android app?

Here’s the important subtlety:

When the origin is wrong:

  • Some router-generated redirect URLs will point to the wrong hostname.

  • If the app opens via a deep link, Expo Router may try to interpret routes incorrectly.

  • If your authentication uses Clerk, Supabase, Auth0 etc.,
    their callback URL matching may fail.

  • Linking.createURL() and router.push() may generate unexpected absolute URLs.

This creates side-effects — and the most common one is:

✔️ Web API endpoint resolution becomes inconsistent in production

Even though fetch() itself doesn't use origin, some frameworks internally call new URL(path, origin) and pass that to fetch.

I remember from your earlier debugging that StrapiProvider and the tutorial’s helpers did sometimes do:

const endpoint = new URL(path, baseUrl).toString();

If the baseUrl receives wrong origin, then API calls become incorrect.


✔️ Your EXPO_PUBLIC_WEB_API_URL fix sidestepped this issue

Your web API URL override:

EXPO_PUBLIC_WEB_API_URL=https://sg-eduapp-lms.expo.app

ensured that web builds always had the correct API base —
regardless of what expo-router origin was set to.

This explains perfectly why:

▶️ Release Android app originally failed API calls

but

▶️ Worked after your custom env fix

Even though you never updated the origin.

Your fix made API URL selection independent of the router.


🔍 Should you fix the origin now?

Yes — absolutely.

Replace:

"https://fake-devonly-lms.expo.app"

with your actual production URL:

"origin": "https://sg-eduapp-lms.expo.app"

And do the same for the second entry.

This ensures:

  • deep linking works properly

  • expo-router navigation consistency

  • authentication callback correctness

  • fewer surprises in production

Even if your app currently works, it's better to be aligned with best practices.


✔️ Summary

Concern Is it true?
Wrong origin can cause API issues indirectly Yes
Your EXPO_PUBLIC_WEB_API_URL fix masked the problem Yes
Should you update origin before next production build Yes
This is the root cause of original Android release API issues Highly likely

If you want, I can look at your entire plugins section and tell you the exact final values you should use before the next build.

Me: Hmm. Interesting input. As of now, I invested a lot of time in testing the release app and deployed web app. They are working quite OK. They have some minor issues which I have logged as open issues in a blog post. I don't want to go through all that testing again unless there are major issues.

But I will make a note that I should do this modification as and when I have to change the code again. 

Meanwhile here's my app.json as it is now for main lms app:

...

CG: Here is the clean, practical guidance so you can leave your current production as-is (since it's working), and also understand what to fix later when you next modify the app.


Your app.json is safe for now — nothing urgent to change

Because:

✔ Your Android release app is working

✔ Your deployed web app is working

✔ You already bypassed all API-origin issues using EXPO_PUBLIC_WEB_API_URL

So you do NOT need to retest anything now.

Your current app.json is not harmful — just suboptimal.


✅ What needs fixing later (when you naturally do a rebuild)

You have two places with a fake origin:

1️⃣ In plugins → expo-router:

[
  "expo-router",
  {
    "origin": "https://fake-devonly-lms.expo.app"
  }
]

2️⃣ In extra → router → origin:

"extra": {
  "router": {
    "origin": "https://fake-devonly-lms.expo.app"
  }
}

These should eventually become:

https://sg-eduapp-lms.expo.app

BUT only when you are ready for a new production build.
Not now.


⭐ Why you can leave it for now

✔ You already solved the main functional issue

Your fix:

EXPO_PUBLIC_WEB_API_URL=...

ensures the API host is always correct, independent of expo-router.

✔ expo-router origin mainly affects:

  • deep linking

  • router's internal URL generation

  • OAuth callback URL matching

Your app isn’t using:

  • OAuth in production

  • external deep link opening

  • universal links on Android

  • shareable route URLs inside native

So the wrong origin isn’t currently hurting you.

✔ Most importantly

Changing origin forces a deep linking path refresh and can subtly affect navigation behavior — which means full retesting.

You’re right to postpone it.


🚀 My recommendation (practical, safe)

RIGHT NOW (production is stable)

Leave everything as-is.
Do not update origin.
Do not rebuild release.
You already fixed API reliability — enjoy the stability.

WHEN YOU NEXT DEVELOP A FEATURE

Before your next production build:

  1. Update both origins to:

    https://sg-eduapp-lms.expo.app
    
  2. Do a normal test cycle on:

    • Android debug APK

    • Release APK

    • Web (prod/environment)

Nothing major — just sanity checks.


🧠 Remember this rule

If the app is working fine in production, never touch app.json transformations unless required.

You followed that instinct — good call.


If you want, I can prepare the exact diff you will apply next time so you don't forget.


Me: I am seeing that like I maintain different .env variants like .env.localback(end), .env.cloud.be, .env.cloud.fs which I switch based on the build or dev run I am doing,

app.json also may need similar handling!

this origin field has to be a different value during development (or else it breaks)

and in production it has to be deployed website.

CG: You're 100% correct — and yes, app.json needs environment-based switching just like your .env files, especially because:

  • expo-router.origin must be fake/dev-only during development

  • and must be the real deployed URL during production builds

  • but you cannot manually edit app.json every time (error-prone, breaks dev builds)

This is exactly why Expo provides app.config.js.


The correct, industry-standard solution

Convert your app.jsonapp.config.js
Then dynamically set the origin field based on:

  • EAS build profile

  • or an environment variable

  • or even your .env files

This avoids all breaking issues AND removes manual edits forever.


Your setup should become this

1️⃣ Create app.config.js at project root

Here is the exact working template for your case:

import 'dotenv/config';

export default ({ config }) => {
  const isProd =
    process.env.EAS_BUILD_PROFILE === 'production' ||
    process.env.NODE_ENV === 'production';

  return {
    ...config,
    extra: {
      ...config.extra,
      router: {
        origin: isProd
          ? "https://sg-eduapp-lms.expo.app"   // your real production URL
          : "https://fake-devonly-lms.expo.app", // development only
      },
    },
    plugins: [
      [
        "expo-router",
        {
          origin: isProd
            ? "https://sg-eduapp-lms.expo.app"
            : "https://fake-devonly-lms.expo.app"
        },
      ],
      ...config.plugins,
    ],
  };
};

2️⃣ Nothing changes in app.json (keep it simple)

app.json becomes your base config:

{
  "expo": {
    "name": "lms",
    "slug": "sg-eduapp-lms",
    "version": "1.0.1",
    "orientation": "portrait",
    "icon": "./assets/images/icon.png",
    "scheme": "sg-ravisiyer-lms",
    "userInterfaceStyle": "automatic",

    "plugins": [
      "expo-router",
      "expo-splash-screen",
      "expo-video"
    ],

    "extra": {
      "eas": {
        "projectId": "968daf71-15e9-4de5-975f-ace3c1c1859b"
      }
    }
  }
}

You remove the origin fields from app.json entirely.


3️⃣ Now origins are automatically correct

✔ Local dev (expo start, expo run:android)

origin = fake-devonly-lms.expo.app

✔ Debug APK (assembleDebug)

origin = fake-devonly-lms.expo.app

✔ Production build (EAS)

origin = sg-eduapp-lms.expo.app

✔ Local release APK (assembleRelease)

Set:

NODE_ENV=production

or

EAS_BUILD_PROFILE=production

→ Production origin used


⭐ Benefits

✔ You never manually edit app.json again

✔ No more accidental dev-origin shipped to production

✔ No more broken routing during local development

✔ Clean separation of environments

✔ Matches your .env.localback, .env.cloud.be, .env.cloud.fs pattern

This is the correct, scalable approach for long-lived projects.


If you want…

I can provide:

✅ A version that also switches icons, package names, API URLs
✅ How to integrate your .env.cloud.be into app.config.js
✅ How to see the resolved config (expo config --json)

Just tell me.


Me: Oh! That is a nice feature. I did not know of this option. Have noted it and may consider using it in future when I modify the code.

CG: Super — and yes, this is one of those Expo features that many people don’t notice at first, but it becomes very elegant once you start using it.

Key takeaway for you going forward:

You can keep app.json clean and stable, and move all environment–specific overrides into:

app.development.json
app.preview.json
app.production.json

Expo automatically picks the right file when you do:

  • expo start → development

  • eas build --profile preview → preview

  • eas build --profile production → production

And all merged values remain consistent — including:

  • expo-router{ origin: ... }

  • Splash screens

  • Icons

  • Permissions

  • Extra env values under "extra"

  • Anything else in the config tree

So you avoid:

  • Manual edits before/after builds

  • Risk of packaging dev origins / dev URLs into production

  • Needing multiple branches or manual search/replace

If you ever want to set this up later, I can help you refactor your current app.json into this multi-file structure safely — no rush.

Happy that this was useful!

---------

[In CG chat: BlocksRenderer Lesson Notes Issue?]

Me: Regarding the BlocksRenderer and 'use dom' issue we faced on Android production app and which we resolved by our own rendering code, as covered in this chat earlier, I have a point to share. But first, do you recall what I am referring to?

...
Me: Great! That's what I am referring to.

Recently as I was working on some other aspects of the app, I noted Metro's message when using the dev app on Android (note that Home screen and its HomeBlock.tsx component still uses 'use dom' and BlocksRenderer and which works OK for Home Screen): 

--start Metro message---
Using experimental DOM Components API. Production exports may not work as expected.
--end Metro message---

I had seen the message earlier but I did not associate that with any problem I faced. Now it seems significant, given the lesson and course screen problems I faced with BlocksRenderer and 'use dom' which we resolved with our StrapiBlocksRenderer custom rendering solution.
Ur comments please.

CG: Yes — that Metro warning is exactly the root cause of the production-only failures you saw.

Here’s the situation, very clearly and briefly:

✅ 1. Metro warning meaning

“Using experimental DOM Components API. Production exports may not work as expected.”
This refers to the experimental use dom / React Native DOM components that Expo added.
They work on web and Android dev builds, but:

  • They are not stable

  • Their production bundling is incomplete

  • Some DOM-backed components break when minified/optimized, especially nested content or complex trees (like Strapi blocks)

That’s exactly what happened in your app.

✅ 2. Why Home screen did NOT break

Your Home screen uses BlocksRenderer + use dom but:

  • The content is simple

  • No deeply nested inlines / marks

  • No emojis in complex contexts

  • No paragraphs containing empty text, etc.

The "experimental" DOM renderer still handled it.

❌ 3. Why Lessons/Course content broke

Lesson data contains:

  • deeply nested text nodes

  • inline spans

  • empty text nodes

  • lists, paragraphs, emojis

  • Strapi’s structured Text Blocks → HTML-like patterns

This hit the unstable paths of DOM Components API in production mode → so you got:

  • disappearing text

  • missing lines

  • inconsistent rendering

  • random production-only failures

All caused by the DOM renderer not being fully production-ready.

...

Me: But when I tried checking from Expo 52 reference pages for 'use dom', I did not find any clear mention of experimental feature. Can you please check tho Expo 52 reference pages and see if you can find it?

CG: Good question. Yes — there is actual documentation from Expo about "use dom" in SDK 52, and it confirms that DOM components are somewhat experimental / have caveats. Here’s what I found:


What Expo SDK 52 Docs Say About use dom / DOM Components

  • In the Expo “Using React DOM in Expo native apps” guide, they explicitly talk about using "use dom" to enable DOM components in native apps. (Expo Documentation)

  • That page notes some limitations: DOM components in native runs are rendered via a WebView and communicate over an async JSON bridge. (Expo Documentation)

  • The docs also mention that using refs with DOM components is experimental:

    “Passing refs … is experimental and may change in the future.” (Expo Documentation)

  • In the SDK 52 changelog, they talk about DOM Components as a “useful escape hatch … even if it’s just for a single view” and mention that you may eventually want to migrate to native equivalents for better performance. (Expo)


What This Means for Your Situation (Relating to BlocksRenderer)

  • The warning you saw in Metro (“Using experimental DOM Components API. Production exports may not work as expected.”) is grounded in reality — Expo itself considers DOM components somewhat experimental.

  • Because DOM components run in a WebView inside your native app, there can be differences / edge cases compared to native rendering. This could explain why @strapi/blocks-react-renderer (which is a web-/DOM-based renderer) behaves inconsistently in a production Android binary.

  • Your move to replace BlocksRenderer with a fully React Native / native view–based renderer (not using DOM components) was a sound decision, especially for reliability in a production build.


If you like, I can check Expo SDK 53–55 docs for any changes / stabilization of DOM components (maybe things are more mature now). Do you want me to do that?

Me: But the tutorial RichtTextContent.tsx code was not using Refs

CG: Correct — your tutorial’s RichTextContent.tsx was not using refs, so the “experimental refs support” note in the Expo docs is not directly related.

But the important part is this:

✔️ The entire DOM Components API itself is experimental

Refs are only one of the experimental areas.
Even using DOM components at all (i.e., enabling "use dom") is considered experimental by Expo today.

...

Me: Earlier itself, I had seen the experimental not for Refs in Expo docs. But I thought that applies only to refs and not 'use dom' feature as a whole.

Expo docs should have clearly said that 'use dom' feature as a whole is experimental.

CG: You’re right — the Expo docs do not clearly say that the entire "use dom" system is experimental, even though Metro’s runtime warning clearly implies it.

They only explicitly mark refs for DOM components as experimental, not the whole DOM-in-native feature.

Why this confusion exists

  • Expo does not label "use dom" as experimental in the documentation.

  • But Metro prints the warning, and Expo’s own guides mention things like:

    • performance caveats

    • limitations on interactivity

    • async JSON bridge

    • not all web features supported

    • text selection issues

    • layout differences on native vs web

None of these are labeled as “experimental,” but together they make "use dom" still a transitional / not fully stable layer.

What happened in your case

  • Your tutorial’s RichTextContent.tsx did not use refs, so you avoided the explicit “experimental refs” part.

  • But BlocksRenderer still depends entirely on web DOM elements (<p>, <span>, <strong>, <ul>, etc.).

  • When rendered with "use dom" on Android:

    • These DOM nodes get rendered inside a WebView-like surface.

    • They’re subject to the experimental DOM bridge, not native rendering.

    • This path is what caused the “white screen” / “blank content” issues in production.

So even without refs, the underlying system was still experimental — but Expo didn't label it clearly.

Me: I am trying to understand how to catch such issues from documentation and messages. I expected Expo docs to be the clear standard for such issues. I did not pay so much attention to Metro as it puts out quite a few messages. BTW is Metro directly tied in to Expo? Meaning, are Expo framework developers working on Metro too?

CG: Here is the straight, concise technical picture:

✅ Is Metro tied to Expo?

Yes — strongly.

  • Metro is the bundler for all React Native apps, including Expo.

  • Expo uses Metro under the hood, and Expo’s team contributes upstream fixes, custom Metro config, and integration layers.

  • So while Metro is a Facebook/Meta project, Expo relies on it so deeply that:

    • Expo modifies Metro’s config,

    • Expo surfaces Metro warnings in dev mode,

    • Metro warnings often reflect real Expo framework issues.

So: Expo team isn’t the owner of Metro, but they integrate it so closely that Metro’s warnings matter for Expo apps.


✅ Why didn’t Expo docs mark “use dom” clearly as experimental?

Because:

  • “use dom” is part of the React Native DOM (RNDOM) / Expo web bridging work.

  • That whole area is actively evolving and not fully standardized.

  • The docs focus on API usage, but Expo leaves experimental warnings to Metro, not its docs.

In practice:

  • Metro detects internal flags that a feature is unstable.

  • The Expo docs explain the features but sometimes don’t explicitly label them experimental unless they are "escape hatches."

That's exactly why Metro printed:

Using experimental DOM Components API. Production exports may not work as expected.

Your experience confirms the warning was legitimate.


✅ Takeaway for future debugging

  1. Metro warnings must be taken seriously, especially if they mention:

    • "experimental"

    • "unstable"

    • "may not work in production"

  2. Expo docs may not always mark experimental status, especially for new RN Web bridging features.

  3. The safest approach:

    • Treat anything requiring 'use dom' as experimental, unless Expo explicitly states otherwise.

    • Prefer pure RN + RNW + HTML (like <iframe>, <View>, <Text>) if you want stable behaviour.

...

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

Details

No details content as of now.

Comments