From b6928d4e335b2ee4cd3b5ae4035f7a87187e71d2 Mon Sep 17 00:00:00 2001 From: Alan Hughes <30924086+alanjhughes@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:29:55 +0100 Subject: [PATCH 1/8] [android][updates] Repair launchable updates that are missing their launch asset (#49470) # Why An update row can end up with status READY but no launch asset row. Registration is now transactional (#49130) so new rows can no longer be committed in this state, but rows corrupted on older SDKs persist through library upgrades, and any future cause would land in the same state. Today such a row is selected for launch, fails with "Launch asset not found for update" on every cold start, and never heals: the loader short-circuits on READY so it never re-registers the assets, and the launcher keeps selecting the same broken row. This brings back the changes in #48733 by @martintreurnicht, which was closed in favor of #49130. # How - DatabaseLauncher.getLaunchableUpdate skips any non-DEVELOPMENT update whose launch asset row is missing and logs a warning. Selection can then fall back to an older complete update, the embedded update, or nothing, instead of a row that can never launch. - Loader.processUpdate's READY short-circuit now also requires the launch asset to exist. A broken row falls through to downloadAllAssets, which re-registers the assets and repairs it on the next load pass The launcher guard is the safety net until repair happens, and the loader guard is the repair path. DEVELOPMENT rows are exempt because they legitimately have no asset rows. # Test Plan Added new tests --- packages/expo-updates/CHANGELOG.md | 1 + .../updates/launcher/DatabaseLauncherTest.kt | 63 +++++++++++++++ .../updates/loader/EmbeddedLoaderTest.kt | 79 +++++++++++++++++++ .../updates/loader/RemoteLoaderTest.kt | 10 +++ .../updates/launcher/DatabaseLauncher.kt | 9 +++ .../expo/modules/updates/loader/Loader.kt | 6 +- 6 files changed, 167 insertions(+), 1 deletion(-) diff --git a/packages/expo-updates/CHANGELOG.md b/packages/expo-updates/CHANGELOG.md index a6a9ce6de09027..098359201dee71 100644 --- a/packages/expo-updates/CHANGELOG.md +++ b/packages/expo-updates/CHANGELOG.md @@ -32,6 +32,7 @@ - [iOS] Register a downloaded update's assets, links, and ready status in a single transaction, so an interrupted or partially failed registration leaves no partial state behind. ([#49458](https://github.com/expo/expo/pull/49458) by [@alanjhughes](https://github.com/alanjhughes)) - [iOS] Report a specific launch asset not found error when a launchable update has no linked launch asset, instead of failing silently or, for an update with no assets at all, hanging on the splash screen. ([#49459](https://github.com/expo/expo/pull/49459) by [@alanjhughes](https://github.com/alanjhughes)) - [iOS] Preserve the cached update's launch failure as the emergency launch reason when the remote check finds no new update, instead of replacing it with the generic AppLoaderTask error. ([#49460](https://github.com/expo/expo/pull/49460) by [@alanjhughes](https://github.com/alanjhughes)) +- [Android] Skip and repair updates that are missing their launch asset instead of selecting them for launch, which previously failed every cold start with "Launch asset not found for update". ([#49470](https://github.com/expo/expo/pull/49470) by [@alanjhughes](https://github.com/alanjhughes), based on [#48733](https://github.com/expo/expo/pull/48733) by [@martintreurnicht](https://github.com/martintreurnicht)) ### ๐Ÿ’ก Others diff --git a/packages/expo-updates/android/src/androidTest/java/expo/modules/updates/launcher/DatabaseLauncherTest.kt b/packages/expo-updates/android/src/androidTest/java/expo/modules/updates/launcher/DatabaseLauncherTest.kt index a8d5cedeb9e469..00c65710bbb7e7 100644 --- a/packages/expo-updates/android/src/androidTest/java/expo/modules/updates/launcher/DatabaseLauncherTest.kt +++ b/packages/expo-updates/android/src/androidTest/java/expo/modules/updates/launcher/DatabaseLauncherTest.kt @@ -10,8 +10,10 @@ import expo.modules.updates.UpdatesConfiguration import expo.modules.updates.db.UpdatesDatabase import expo.modules.updates.db.entity.AssetEntity import expo.modules.updates.db.entity.UpdateEntity +import expo.modules.updates.db.enums.UpdateStatus import expo.modules.updates.logging.UpdatesLogger import expo.modules.updates.selectionpolicy.SelectionPolicy +import expo.modules.updates.selectionpolicy.SelectionPolicyFactory import io.mockk.coEvery import io.mockk.mockk import io.mockk.spyk @@ -90,4 +92,65 @@ class DatabaseLauncherTest { Date().time - sameUpdate.lastAccessed.time < 1000 ) } + + @Test + fun testGetLaunchableUpdate_SkipsUpdateWithMissingLaunchAsset() = runTest { + val configuration = testConfiguration() + + val completeUpdate = readyUpdate(configuration.scopeKey, Date(1000)) + db.updateDao().insertUpdate(completeUpdate) + db.assetDao().insertAssets(listOf(launchAssetEntity()), completeUpdate) + + // newer, but its launch asset was never registered -- launching it would throw + val incompleteUpdate = readyUpdate(configuration.scopeKey, Date(2000)) + db.updateDao().insertUpdate(incompleteUpdate) + + val launchableUpdate = databaseLauncher(configuration).getLaunchableUpdate(db) + + Assert.assertEquals(completeUpdate.id, launchableUpdate?.id) + } + + @Test + fun testGetLaunchableUpdate_NullWhenEveryUpdateIsMissingItsLaunchAsset() = runTest { + val configuration = testConfiguration() + + val incompleteUpdate = readyUpdate(configuration.scopeKey, Date(1000)) + db.updateDao().insertUpdate(incompleteUpdate) + + Assert.assertNull(databaseLauncher(configuration).getLaunchableUpdate(db)) + } + + private fun testConfiguration() = UpdatesConfiguration( + null, + mapOf( + "updateUrl" to Uri.parse("https://example.com"), + "runtimeVersion" to "1.0", + "hasEmbeddedUpdate" to false + ) + ) + + private fun databaseLauncher(configuration: UpdatesConfiguration) = DatabaseLauncher( + context, + configuration, + File("test"), + mockk(), + SelectionPolicyFactory.createFilterAwarePolicy("1.0", configuration), + UpdatesLogger(context.filesDir), + TestScope() + ) + + private fun readyUpdate(scopeKey: String, commitTime: Date) = UpdateEntity( + UUID.randomUUID(), + commitTime, + "1.0", + scopeKey, + JSONObject("{}"), + null, + null + ).apply { status = UpdateStatus.READY } + + private fun launchAssetEntity() = AssetEntity("bundle-1234", "js").apply { + relativePath = "bundle-1234" + isLaunchAsset = true + } } diff --git a/packages/expo-updates/android/src/androidTest/java/expo/modules/updates/loader/EmbeddedLoaderTest.kt b/packages/expo-updates/android/src/androidTest/java/expo/modules/updates/loader/EmbeddedLoaderTest.kt index 3cf27871b709a0..c89b7672241cea 100644 --- a/packages/expo-updates/android/src/androidTest/java/expo/modules/updates/loader/EmbeddedLoaderTest.kt +++ b/packages/expo-updates/android/src/androidTest/java/expo/modules/updates/loader/EmbeddedLoaderTest.kt @@ -292,6 +292,11 @@ class EmbeddedLoaderTest { update.status = UpdateStatus.READY db.updateDao().insertUpdate(update) + val launchAsset = AssetEntity("bundle-1234", "js") + launchAsset.relativePath = "bundle-1234" + launchAsset.isLaunchAsset = true + db.assetDao().insertAssets(listOf(launchAsset), update) + val result = loader.load { _ -> Loader.OnUpdateResponseLoadedResult(shouldDownloadManifestIfPresentInResponse = true) } @@ -302,6 +307,80 @@ class EmbeddedLoaderTest { val updates = db.updateDao().loadAllUpdates() Assert.assertEquals(1, updates.size.toLong()) Assert.assertEquals(UpdateStatus.READY, updates[0].status) + + // short-circuited, so the manifest's assets were never registered + Assert.assertEquals(1, db.assetDao().loadAllAssets().size.toLong()) + } + + @Test + @Throws(IOException::class, NoSuchAlgorithmException::class) + fun testEmbeddedLoader_UpdateExists_ReadyButMissingLaunchAsset() = runTest { + val update = UpdateEntity( + manifest.updateEntity!!.id, + manifest.updateEntity!!.commitTime, + manifest.updateEntity!!.runtimeVersion, + manifest.updateEntity!!.scopeKey, + manifest.updateEntity!!.manifest, + null, + null + ) + update.status = UpdateStatus.READY + db.updateDao().insertUpdate(update) + + val result = loader.load { _ -> + Loader.OnUpdateResponseLoadedResult(shouldDownloadManifestIfPresentInResponse = true) + } + + Assert.assertNotNull(result.updateEntity) + + val updates = db.updateDao().loadAllUpdates() + Assert.assertEquals(1, updates.size.toLong()) + Assert.assertEquals(UpdateStatus.READY, updates[0].status) + + // the incomplete row is repaired rather than short-circuited + Assert.assertNotNull(db.updateDao().loadLaunchAssetForUpdate(update.id)) + Assert.assertEquals(2, db.assetDao().loadAllAssets().size.toLong()) + } + + @Test + @Throws(IOException::class, NoSuchAlgorithmException::class) + fun testEmbeddedLoader_UpdateExists_ReadyWithOrphanedLaunchAssetRow() = runTest { + val update = UpdateEntity( + manifest.updateEntity!!.id, + manifest.updateEntity!!.commitTime, + manifest.updateEntity!!.runtimeVersion, + manifest.updateEntity!!.scopeKey, + manifest.updateEntity!!.manifest, + null, + null + ) + update.status = UpdateStatus.READY + db.updateDao().insertUpdate(update) + + // the launch asset row and join row exist on disk and in the database, but the registration + // was interrupted before launch_asset_id was set on the update + val orphanedLaunchAsset = AssetEntity("bundle-${update.id}", "js") + orphanedLaunchAsset.relativePath = "bundle-1234" + db.assetDao().insertAssets(listOf(orphanedLaunchAsset), update) + Assert.assertNull(db.updateDao().loadLaunchAssetForUpdate(update.id)) + + every { mockLoaderFiles.fileExists(any(), any(), any()) } answers { + thirdArg().contains("bundle-1234") + } + + val result = loader.load { _ -> + Loader.OnUpdateResponseLoadedResult(shouldDownloadManifestIfPresentInResponse = true) + } + + Assert.assertNotNull(result.updateEntity) + + val updates = db.updateDao().loadAllUpdates() + Assert.assertEquals(1, updates.size.toLong()) + Assert.assertEquals(UpdateStatus.READY, updates[0].status) + + // repaired through the existing-asset branch: the orphaned row is reused, not re-registered + Assert.assertNotNull(db.updateDao().loadLaunchAssetForUpdate(update.id)) + Assert.assertEquals(2, db.assetDao().loadAllAssets().size.toLong()) } @Test diff --git a/packages/expo-updates/android/src/androidTest/java/expo/modules/updates/loader/RemoteLoaderTest.kt b/packages/expo-updates/android/src/androidTest/java/expo/modules/updates/loader/RemoteLoaderTest.kt index 09dc682781f9c3..29a57a3584df41 100644 --- a/packages/expo-updates/android/src/androidTest/java/expo/modules/updates/loader/RemoteLoaderTest.kt +++ b/packages/expo-updates/android/src/androidTest/java/expo/modules/updates/loader/RemoteLoaderTest.kt @@ -195,6 +195,11 @@ class RemoteLoaderTest { update.status = UpdateStatus.READY db.updateDao().insertUpdate(update) + val launchAsset = AssetEntity("bundle-1234", "js") + launchAsset.relativePath = "bundle-1234" + launchAsset.isLaunchAsset = true + db.assetDao().insertAssets(listOf(launchAsset), update) + val result = loader.load { _ -> Loader.OnUpdateResponseLoadedResult(shouldDownloadManifestIfPresentInResponse = true) } @@ -251,6 +256,11 @@ class RemoteLoaderTest { update.status = UpdateStatus.READY db.updateDao().insertUpdate(update) + val launchAsset = AssetEntity("bundle-1234", "js") + launchAsset.relativePath = "bundle-1234" + launchAsset.isLaunchAsset = true + db.assetDao().insertAssets(listOf(launchAsset), update) + val result = loader.load { _ -> Loader.OnUpdateResponseLoadedResult(shouldDownloadManifestIfPresentInResponse = true) } diff --git a/packages/expo-updates/android/src/main/java/expo/modules/updates/launcher/DatabaseLauncher.kt b/packages/expo-updates/android/src/main/java/expo/modules/updates/launcher/DatabaseLauncher.kt index 92e9bc592c438f..13969138c78c91 100644 --- a/packages/expo-updates/android/src/main/java/expo/modules/updates/launcher/DatabaseLauncher.kt +++ b/packages/expo-updates/android/src/main/java/expo/modules/updates/launcher/DatabaseLauncher.kt @@ -161,6 +161,15 @@ class DatabaseLauncher( if (!configuration.hasEmbeddedUpdate && embeddedUpdate?.updateEntity?.id == update.id) { continue } + + // An update with no launch asset can never launch. Excluding it here lets the loader + // re-run and repair the row instead of failing every cold start. + if (update.status != UpdateStatus.DEVELOPMENT && + database.updateDao().loadLaunchAssetForUpdate(update.id) == null + ) { + logger.warn("Skipping launchable update with no launch asset. Debug info: ${update.debugInfo()}") + continue + } filteredLaunchableUpdates.add(update) } val manifestFilters = ManifestMetadata.getManifestFilters(database, configuration) diff --git a/packages/expo-updates/android/src/main/java/expo/modules/updates/loader/Loader.kt b/packages/expo-updates/android/src/main/java/expo/modules/updates/loader/Loader.kt index 862228c8e7149f..be2a19fe32f7ad 100644 --- a/packages/expo-updates/android/src/main/java/expo/modules/updates/loader/Loader.kt +++ b/packages/expo-updates/android/src/main/java/expo/modules/updates/loader/Loader.kt @@ -166,7 +166,11 @@ abstract class Loader protected constructor( database.updateDao().setUpdateScopeKey(existingUpdateEntity, newUpdateEntity.scopeKey) } - if (existingUpdateEntity != null && existingUpdateEntity.status == UpdateStatus.READY) { + // A READY update with no launch asset is not actually ready. Fall through to + // downloadAllAssets so its assets are re-registered instead of staying broken forever. + if (existingUpdateEntity != null && existingUpdateEntity.status == UpdateStatus.READY && + database.updateDao().loadLaunchAssetForUpdate(existingUpdateEntity.id) != null + ) { // hooray, we already have this update downloaded and ready to go! updateEntity = existingUpdateEntity return finish() From 6e8fd0ebabfb7bc4b5ba2abf8c8f62f6332786ba Mon Sep 17 00:00:00 2001 From: Alan Hughes <30924086+alanjhughes@users.noreply.github.com> Date: Fri, 28 Aug 2026 08:29:56 +0100 Subject: [PATCH 2/8] [android][updates] Add a stress test for concurrent asset registration in Loader (#49471) # Why #43966 fixed lost asset writes by making Loader's bookkeeping collections thread safe. That race silently dropped assets from updates that were then marked READY, which bricks the app at launch, and nothing in the test suite would catch a regression: the existing loader tests run on a single-threaded test dispatcher, so the download completions can never overlap. # How Adds LoaderStressTest, which loads a generated manifest of 100 assets plus the launch asset through a real RemoteLoader on a Dispatchers.IO scope, with small random delays in the mocked downloads so completions genuinely interleave. It runs five iterations against fresh in-memory databases and asserts the update is READY, has its launch asset, and has every asset row registered. # Test Plan With #43966's synchronization temporarily reverted to plain collections, the test fails on the first iteration with a ConcurrentModificationException out of notifyAssetLoadProgress. With the synchronization in place, all iterations pass. --- .../updates/loader/LoaderStressTest.kt | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 packages/expo-updates/android/src/androidTest/java/expo/modules/updates/loader/LoaderStressTest.kt diff --git a/packages/expo-updates/android/src/androidTest/java/expo/modules/updates/loader/LoaderStressTest.kt b/packages/expo-updates/android/src/androidTest/java/expo/modules/updates/loader/LoaderStressTest.kt new file mode 100644 index 00000000000000..f92c5b2c9dfba9 --- /dev/null +++ b/packages/expo-updates/android/src/androidTest/java/expo/modules/updates/loader/LoaderStressTest.kt @@ -0,0 +1,144 @@ +package expo.modules.updates.loader + +import android.content.Context +import android.net.Uri +import androidx.room.Room +import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner +import androidx.test.platform.app.InstrumentationRegistry +import expo.modules.manifests.core.ExpoUpdatesManifest +import expo.modules.updates.UpdatesConfiguration +import expo.modules.updates.db.UpdatesDatabase +import expo.modules.updates.db.entity.AssetEntity +import expo.modules.updates.db.enums.UpdateStatus +import expo.modules.updates.logging.UpdatesLogger +import expo.modules.updates.manifest.ExpoUpdatesUpdate +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.runTest +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import java.util.UUID + +/** + * Loads an update with many assets downloading concurrently on real IO threads. The loader's + * bookkeeping collections are mutated from every download job, so this fails if they are ever + * reverted to unsynchronized collections: assets silently vanish from the database and the + * update is marked READY without them, which bricks the app at launch. + */ +@RunWith(AndroidJUnit4ClassRunner::class) +class LoaderStressTest { + private lateinit var context: Context + private lateinit var configuration: UpdatesConfiguration + private lateinit var logger: UpdatesLogger + + @Before + fun setup() { + context = InstrumentationRegistry.getInstrumentation().targetContext + configuration = UpdatesConfiguration( + null, + mapOf( + "updateUrl" to Uri.parse("https://exp.host/@test/test"), + "runtimeVersion" to "1" + ) + ) + logger = UpdatesLogger(context.filesDir) + } + + @Test + fun testRemoteLoader_ManyConcurrentAssetDownloads_RegistersEveryAsset() = runTest { + repeat(ITERATIONS) { + val db = Room.inMemoryDatabaseBuilder(context, UpdatesDatabase::class.java).build() + try { + val manifest = ExpoUpdatesUpdate.fromExpoUpdatesManifest( + ExpoUpdatesManifest(JSONObject(manifestBodyWithAssets(ASSET_COUNT))), + null, + configuration + ) + + val mockFileDownloader = mockk() + coEvery { mockFileDownloader.downloadRemoteUpdate(any()) } returns UpdateResponse( + responseHeaderData = null, + manifestUpdateResponsePart = UpdateResponsePart.ManifestUpdateResponsePart(manifest), + directiveUpdateResponsePart = null + ) + coEvery { mockFileDownloader.downloadAsset(any(), any(), any(), any(), any(), any()) } coAnswers { + // jitter so download completions overlap instead of serializing + delay((0..2).random().toLong()) + FileDownloader.AssetDownloadResult(firstArg(), true) + } + + val loader = RemoteLoader( + context, + configuration, + logger, + db, + mockFileDownloader, + File("testDirectory"), + null, + mockk(relaxed = true), + CoroutineScope(SupervisorJob() + Dispatchers.IO) + ) + + val result = loader.load { + Loader.OnUpdateResponseLoadedResult(shouldDownloadManifestIfPresentInResponse = true) + } + + Assert.assertNotNull(result.updateEntity) + + val updates = db.updateDao().loadAllUpdates() + Assert.assertEquals(1, updates.size) + Assert.assertEquals(UpdateStatus.READY, updates[0].status) + Assert.assertNotNull(db.updateDao().loadLaunchAssetForUpdate(updates[0].id)) + Assert.assertEquals(ASSET_COUNT + 1, db.assetDao().loadAllAssets().size) + } finally { + db.close() + } + } + } + + private fun manifestBodyWithAssets(count: Int): String { + val assets = JSONArray() + for (i in 0 until count) { + assets.put( + JSONObject().apply { + put("hash", "hash-$i") + put("key", "asset-$i.jpg") + put("contentType", "image/jpeg") + put("url", "http://192.168.64.1:3000/api/assets?asset=$i") + put("fileExtension", ".jpg") + } + ) + } + return JSONObject().apply { + put("id", UUID.randomUUID().toString()) + put("createdAt", "2021-11-23T00:57:14.437Z") + put("runtimeVersion", "1") + put("assets", assets) + put( + "launchAsset", + JSONObject().apply { + put("hash", "hash-bundle") + put("key", "bundle.js") + put("contentType", "application/javascript") + put("url", "http://192.168.64.1:3000/api/assets?asset=bundle") + put("fileExtension", ".bundle") + } + ) + put("extra", JSONObject().apply { put("scopeKey", "@test/app") }) + }.toString() + } + + companion object { + private const val ASSET_COUNT = 100 + private const val ITERATIONS = 5 + } +} From 4c1722890b2a62eb0eb148a610c9dbb59a933c92 Mon Sep 17 00:00:00 2001 From: Mad Dinh <70377017+dennytosp@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:27:09 +0700 Subject: [PATCH 3/8] [file-system] Fix BYOB stream reads into a view with a non-zero offset (#49234) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Why `File.readableStream()` returns zeroed bytes, and silently corrupts the caller's buffer, when it is read with a BYOB reader whose view starts at a non-zero offset. `byobRequest.view` covers the region of the caller's buffer that the stream still has to fill. `byteLength` is already that region's size, and `byteOffset` is where it begins in the buffer. [`FileSystemReadableStreamSource.pull`](https://github.com/expo/expo/blob/main/packages/expo-file-system/src/internal/streams.ts#L28-L41) treated both as if they were measured from the start of the buffer: ```ts const bytes = await this.handle.readBytes(theView.byteLength - theView.byteOffset); ... if (theView instanceof Uint8Array) { theView.set(bytes, theView.byteOffset); } else { const array = new Uint8Array(theView.buffer); for (let i = 0; i < bytes.length; i++) { array[i + (theView.byteOffset ?? 0)] = bytes[i]!; } } ``` Two defects: 1. `readBytes(byteLength - byteOffset)` under-reads by `byteOffset`, and asks for a non-positive length once `byteOffset >= byteLength`. 2. `theView.set(bytes, theView.byteOffset)` โ€” `set` takes an offset **relative to the view**, and the view already starts at `byteOffset`, so the offset is applied twice. The two branches of that `if` disagree with each other. The `else` branch indexes a whole-buffer `Uint8Array` by `i + byteOffset`, which is correct. Only the `Uint8Array` branch double-applies. `byobRequest.view` has a non-zero `byteOffset` whenever the caller passes an offset view, and also on any continuation of a partially-filled BYOB read, since the spec builds the view as `(buffer, byteOffset + bytesFilled, byteLength - bytesFilled)`. # How `byteLength` is the amount to read, and the write goes through a `Uint8Array` bounded to the view's region: ```ts const bytes = await this.handle.readBytes(theView.byteLength); ... new Uint8Array(theView.buffer, theView.byteOffset, theView.byteLength).set(bytes); ``` That is correct for every view type, so the `instanceof` branch goes away. The `TODO` above it still stands โ€” a native method writing straight into the view at an offset would avoid this copy. Partial reads are unaffected: a short `bytes` writes only what it has, and `respond(bytes.length)` is unchanged. # Test Plan New unit tests in `src/internal/__tests__/streams-test.ts`. They drive `pull()` with a `byobRequest` stand-in, because jsdom has no `ReadableStream` and the Web jest project would otherwise fail with `ReferenceError: ReadableStream is not defined`. Before, on `main`: ``` Tests: 12 failed, 12 passed, 24 total Test Suites: 4 failed, 4 total ``` The three failing cases, in all four projects: ``` โœ• fills a BYOB view that starts at a non-zero offset in its buffer โœ• requests as many bytes as the BYOB view can hold โœ• fills a BYOB view that is not a Uint8Array ``` After: ``` PASS Node | PASS Web | PASS Android | PASS iOS Tests: 24 passed, 24 total ``` I also checked this end to end against Node's real `ReadableStream`, reading a file of `1..64` through a BYOB reader with `new Uint8Array(new ArrayBuffer(32), 8, 16)`. `main`: ``` value bytes : [0,0,0,0,0,0,0,0] expected : [1,2,3,4,5,6,7,8] whole buffer : [0,0,0,0,0,0,0,0, 0,0,0,0,0,0,0,0, 1,2,3,4,5,6,7,8, 0,0,0,0,0,0,0,0] ``` The caller gets eight zero bytes, and the file data is written at offset 16 โ€” past the range `respond()` reported as filled, so it also clobbers whatever the caller had after the view. This PR: ``` value bytes : [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16] expected : [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16] whole buffer : [0,0,0,0,0,0,0,0, 1,2,...,16, 0,0,0,0,0,0,0,0] ``` `et check-packages expo-file-system` -> `๐Ÿ All checks passed`. # Checklist - [x] Added a `CHANGELOG.md` entry. - [x] Added tests that fail on `main` and pass here. - [x] Conforms to the [documentation writing style guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md). --- packages/expo-file-system/CHANGELOG.md | 1 + .../src/internal/__tests__/streams-test.ts | 106 ++++++++++++++++++ .../expo-file-system/src/internal/streams.ts | 13 +-- 3 files changed, 111 insertions(+), 9 deletions(-) create mode 100644 packages/expo-file-system/src/internal/__tests__/streams-test.ts diff --git a/packages/expo-file-system/CHANGELOG.md b/packages/expo-file-system/CHANGELOG.md index 4e72af0f16fa68..8196fbb6930bd8 100644 --- a/packages/expo-file-system/CHANGELOG.md +++ b/packages/expo-file-system/CHANGELOG.md @@ -14,6 +14,7 @@ ### ๐Ÿ› Bug fixes +- Fix `File.readableStream()` returning zeroed bytes and writing past the requested region when a BYOB read targets a view that starts at a non-zero offset. ([#49234](https://github.com/expo/expo/pull/49234) by [@dennytosp](https://github.com/dennytosp)) - [Android][iOS] Fix `File.size` returning `null` for a missing or unreadable file. ([#49086](https://github.com/expo/expo/pull/49086)) by [@ACHP](https://github.com/ACHP)) - [iOS] Fix wrong permissions for text() and bytes(). ([#42422](https://github.com/expo/expo/pull/42422)) by [@simoneldevig](https://github.com/simoneldevig)) - Fixed `copyAsync` on iOS copying the unedited original when a `ph://` asset has edits applied in Photos. ([#48248](https://github.com/expo/expo/pull/48248) by [@CoffeeFlux](https://github.com/CoffeeFlux)) diff --git a/packages/expo-file-system/src/internal/__tests__/streams-test.ts b/packages/expo-file-system/src/internal/__tests__/streams-test.ts new file mode 100644 index 00000000000000..f730d729217564 --- /dev/null +++ b/packages/expo-file-system/src/internal/__tests__/streams-test.ts @@ -0,0 +1,106 @@ +import type { FileHandle } from '../../File.types'; +import { FileSystemReadableStreamSource } from '../streams'; + +const CONTENTS = new Uint8Array(64).map((_, index) => index + 1); + +/** A handle over an in-memory buffer, reading sequentially like a real file handle. */ +function createHandle(contents: Uint8Array = CONTENTS) { + let position = 0; + const requestedLengths: number[] = []; + const handle = { + readBytes: async (length: number) => { + requestedLengths.push(length); + const slice = contents.subarray(position, position + length); + position += slice.length; + return new Uint8Array(slice); + }, + close: () => {}, + }; + return { handle: handle as unknown as FileHandle, requestedLengths }; +} + +/** + * A `ReadableByteStreamController` stand-in. `byobRequest.view` covers the region of the + * caller's buffer the stream still has to fill, which is what the spec hands to `pull`. + * jsdom has no `ReadableStream`, so the source is driven directly. + */ +function createController(view: ArrayBufferView) { + const responded: number[] = []; + const controller = { + byobRequest: { view, respond: (bytesWritten: number) => responded.push(bytesWritten) }, + close: () => {}, + enqueue: () => {}, + }; + return { controller: controller as unknown as ReadableByteStreamController, responded }; +} + +describe(FileSystemReadableStreamSource, () => { + it('fills a BYOB view that starts at a non-zero offset in its buffer', async () => { + const { handle } = createHandle(); + const view = new Uint8Array(new ArrayBuffer(32), 8, 16); + const { controller, responded } = createController(view); + + await new FileSystemReadableStreamSource(handle).pull(controller); + + expect(responded).toEqual([16]); + expect(Array.from(view)).toEqual(Array.from(CONTENTS.subarray(0, 16))); + }); + + it('requests as many bytes as the BYOB view can hold', async () => { + const { handle, requestedLengths } = createHandle(); + const view = new Uint8Array(new ArrayBuffer(32), 8, 16); + const { controller } = createController(view); + + await new FileSystemReadableStreamSource(handle).pull(controller); + + expect(requestedLengths).toEqual([16]); + }); + + it('does not write outside the BYOB view', async () => { + const { handle } = createHandle(); + const buffer = new ArrayBuffer(32); + const view = new Uint8Array(buffer, 8, 16); + const { controller } = createController(view); + + await new FileSystemReadableStreamSource(handle).pull(controller); + + const whole = new Uint8Array(buffer); + expect(Array.from(whole.subarray(0, 8))).toEqual(new Array(8).fill(0)); + expect(Array.from(whole.subarray(24))).toEqual(new Array(8).fill(0)); + }); + + it('fills a BYOB view that starts at offset zero', async () => { + const { handle } = createHandle(); + const view = new Uint8Array(new ArrayBuffer(16)); + const { controller, responded } = createController(view); + + await new FileSystemReadableStreamSource(handle).pull(controller); + + expect(responded).toEqual([16]); + expect(Array.from(view)).toEqual(Array.from(CONTENTS.subarray(0, 16))); + }); + + it('fills a BYOB view that is not a Uint8Array', async () => { + const { handle } = createHandle(); + const buffer = new ArrayBuffer(32); + const view = new Uint16Array(buffer, 8, 8); + const { controller, responded } = createController(view); + + await new FileSystemReadableStreamSource(handle).pull(controller); + + expect(responded).toEqual([16]); + expect(Array.from(new Uint8Array(buffer, 8, 16))).toEqual(Array.from(CONTENTS.subarray(0, 16))); + }); + + it('closes the stream when the handle is exhausted', async () => { + const { handle } = createHandle(new Uint8Array(0)); + const view = new Uint8Array(new ArrayBuffer(32), 8, 16); + const { controller, responded } = createController(view); + const close = jest.spyOn(controller, 'close'); + + await new FileSystemReadableStreamSource(handle).pull(controller); + + expect(close).toHaveBeenCalled(); + expect(responded).toEqual([0]); + }); +}); diff --git a/packages/expo-file-system/src/internal/streams.ts b/packages/expo-file-system/src/internal/streams.ts index 132c676de3e244..52393840c224e2 100644 --- a/packages/expo-file-system/src/internal/streams.ts +++ b/packages/expo-file-system/src/internal/streams.ts @@ -26,20 +26,15 @@ export class FileSystemReadableStreamSource implements UnderlyingByteSource { } // TODO: Optimize by adding a native method that can write into a TypedArray at a given offset. - const bytes = await this.handle.readBytes(theView.byteLength - theView.byteOffset); + // `byteLength` is already the size of the region to fill, so `byteOffset` must not be + // subtracted from it, and `set` takes an offset relative to the view it is called on. + const bytes = await this.handle.readBytes(theView.byteLength); if (bytes.length === 0) { controller.close(); controller.byobRequest.respond(0); return; } - if (theView instanceof Uint8Array) { - theView.set(bytes, theView.byteOffset); - } else { - const array = new Uint8Array(theView.buffer); - for (let i = 0; i < bytes.length; i++) { - array[i + (theView.byteOffset ?? 0)] = bytes[i]!; - } - } + new Uint8Array(theView.buffer, theView.byteOffset, theView.byteLength).set(bytes); controller.byobRequest.respond(bytes.length); } } From bf2a0a895da23d584104d3349081b49b8423907e Mon Sep 17 00:00:00 2001 From: Expo Bot <34669131+expo-bot@users.noreply.github.com> Date: Fri, 28 Aug 2026 01:33:02 -0700 Subject: [PATCH 4/8] [android][notifications] Add a `largeIcon` config plugin property (#49481) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > [!WARNING] > **Agent-authored and NOT human-reviewed.** An automated `/verify --fix` run for #49468 wrote this change and checked it in a sandbox; the reasoning and evidence are in the outcome comment on that issue. Review it as you would any external contribution. Requested by @brentvatne ยท [investigation run](https://github.com/expo/expo/actions/runs/33140294393) ยท refs #49468 The Android code reads the manifest key `expo.modules.notifications.large_notification_icon` and passes it to `setLargeIcon`. The config plugin never writes that key. A project therefore cannot set a notification large icon, and a `largeIcon` key in `app.json` is dropped without a warning. A `TODO` marks the gap. This adds a `largeIcon` property. When it is set, prebuild generates `notification_large_icon.png` for five densities and adds the meta-data entry. With `largeIcon` unset, the prebuilt manifest and every generated drawable are byte-identical to the current release. One case does change: the plugin now clears that key when `largeIcon` is unset, so a custom plugin that writes it must be listed before `expo-notifications`. Both are measured below.
Cause The Android runtime side is complete. `ExpoNotificationBuilder` defines the key, reads it from `ApplicationInfo.metaData`, and applies it: - key: [`ExpoNotificationBuilder.kt` L402-L403](https://github.com/expo/expo/blob/f423e2ae75422c2860cee500b13cee695dbbe558/packages/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/presentation/builders/ExpoNotificationBuilder.kt#L402-L403) - read: [`ExpoNotificationBuilder.kt` L319-L340](https://github.com/expo/expo/blob/f423e2ae75422c2860cee500b13cee695dbbe558/packages/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/presentation/builders/ExpoNotificationBuilder.kt#L319-L340) - use: [`ExpoNotificationBuilder.kt` L150-L155](https://github.com/expo/expo/blob/f423e2ae75422c2860cee500b13cee695dbbe558/packages/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/presentation/builders/ExpoNotificationBuilder.kt#L150-L155) The plugin side is not. `setNotificationConfig` writes only the small icon, the color and the default channel: [`withNotificationsAndroid.ts` L119-L173](https://github.com/expo/expo/blob/f423e2ae75422c2860cee500b13cee695dbbe558/packages/expo-notifications/plugin/src/withNotificationsAndroid.ts#L119-L173). `withNotificationsAndroid` destructures a fixed set of properties, so `largeIcon` in `app.json` reaches nothing: [`withNotificationsAndroid.ts` L261-L270](https://github.com/expo/expo/blob/f423e2ae75422c2860cee500b13cee695dbbe558/packages/expo-notifications/plugin/src/withNotificationsAndroid.ts#L261-L270). The `TODO` sits at [L48-L49](https://github.com/expo/expo/blob/f423e2ae75422c2860cee500b13cee695dbbe558/packages/expo-notifications/plugin/src/withNotificationsAndroid.ts#L48-L49). [#10492](https://github.com/expo/expo/pull/10492) removed large icon support from the managed workflow in 2020, in `ScopedExpoNotificationBuilder.java`. The native reader stayed. The config side was never added back. The 64 dp baseline matches Android's own `notification_large_icon_width` and `notification_large_icon_height`. The small icon keeps its 24 dp baseline.
Verification A fresh `create-expo-app` project on `expo@57.0.17`, `expo-notifications@57.0.15`, `react-native@0.86.3`. Every arm is `npx expo prebuild -p android --clean` with the image cache cleared first. The "this change" arms use `plugin/build/withNotificationsAndroid.js` from this repository's own `pnpm run build` (`sha256:24eb3041โ€ฆ`), copied into the app's `node_modules`. The "published" arms use `sha256:ae1bc74dโ€ฆ`, which matches a fresh `npm i expo-notifications@57.0.15`. | Arm | Plugin | `largeIcon` in app.json | meta-data | drawables | |---|---|---|---|---| | Before | published 57.0.15 | yes | absent | none | | After | this change | yes | present | 5 densities | | Guard | this change | no | absent | none | The after arm adds exactly one line to the generated manifest: ``` ``` Generated drawables in the after arm. The small icon is unchanged: ``` drawable-mdpi/notification_icon.png 24x24 drawable-hdpi/notification_icon.png 36x36 drawable-xhdpi/notification_icon.png 48x48 drawable-xxhdpi/notification_icon.png 72x72 drawable-xxxhdpi/notification_icon.png 96x96 drawable-mdpi/notification_large_icon.png 64x64 drawable-hdpi/notification_large_icon.png 96x96 drawable-xhdpi/notification_large_icon.png 128x128 drawable-xxhdpi/notification_large_icon.png 192x192 drawable-xxxhdpi/notification_large_icon.png 256x256 ``` Guard arm, `largeIcon` unset, published against this change: ``` diff manifest-baseline.xml manifest-guard.xml -> MANIFEST IDENTICAL diff res-baseline.txt res-guard.txt -> DRAWABLES IDENTICAL ``` `res-*.txt` are `sha256sum` lists of every generated `notification*` drawable, so the bytes are identical, not only the file names.
The behaviour that does change The guard arm only covers a project that never had the meta-data key. This change adds an `else` branch that removes `expo.modules.notifications.large_notification_icon` and deletes `notification_large_icon.png` when `largeIcon` is unset. Today the plugin never touches that key or that filename, so a project that writes it from a custom plugin is a separate case. That is a real population, because a custom plugin is currently the only way to set a large icon. All four combinations, with `largeIcon` unset in `app.json` in every row: | Plugin | Custom plugin listed | meta-data entries | `notification_large_icon.png` files | |---|---|---|---| | published 57.0.15 | before `expo-notifications` | 1 | 5 | | published 57.0.15 | after `expo-notifications` | 1 | 5 | | this change | before `expo-notifications` | 1 | 5 | | this change | after `expo-notifications` | 0 | 0 | A plugin listed later has its mods run earlier, so the custom plugin writes first and the `expo-notifications` mod then clears the result. Listing the custom plugin before `expo-notifications` is safe with both versions. This matches how the plugin already treats `icon`, `color` and `defaultChannel`, which it also clears when unset. The difference is that no third party writes those keys. If you would rather not clear this key at all, option 2 in the block below is the smaller change.
Checks run From `packages/expo-notifications` in a full `pnpm install` of this repository at the checkout commit: ``` pnpm run typecheck -> exit 0 pnpm run lint -> Found 0 warnings and 0 errors (115 files) pnpm test -> 13 suites, 69 tests, 1 snapshot, all passing pnpm run build -> exit 0 pnpm run depscheck -> exit 0 npx oxfmt --check -> All matched files use the correct format ``` The test count includes two new plugin tests: one for the five generated large icon files, one for safe and idempotent removal. This change also touches one documentation page, so the `docs-pr` checks ran from `docs/`: ``` pnpm test -> 58 suites, 623 tests, 32 snapshots, all passing pnpm lint-prose -> 0 errors, 0 warnings, 0 suggestions in 1600 files pnpm lint -> oxfmt, tsc and eslint pass; oxlint crashed ``` The `oxlint` step crashed with a Rust panic in `oxc_allocator`. It crashes the same way on the unmodified tree in the same environment, so this change did not cause it.
Not covered - No Android emulator or device run. This change only produces build inputs. The native code that draws the large icon is unchanged. - No bare project with a hand-edited `android/AndroidManifest.xml` that prebuild does not regenerate. Every arm ran `prebuild --clean`. - `largeIcon` is not added to the versioned SDK 57 documentation pages, only to `unversioned`. - A notification that carries its own image still wins over this icon, because `ExpoNotificationBuilder` prefers `notificationContent.getImage`. That behaviour is unchanged.
Options considered 1. **Do nothing and document that the large icon needs a custom config plugin.** Costs nothing in code, but leaves a `TODO` that names this exact work, and leaves every project writing the same plugin by hand. Rejected: the native reader already exists, so only the config side is missing. 2. **Add the property but never clear the key when `largeIcon` is unset.** Drops the `else` branch, so the last row of the table above would read 1 and 5, and no custom plugin could be broken by ordering. Cost: it breaks symmetry with `icon`, `color` and `defaultChannel`, and a bare project that removes `largeIcon` from `app.json` keeps a stale entry and a stale drawable until a clean prebuild. Rejected on that inconsistency, but it is the option to pick if the ordering interaction is judged worse. 3. **Accept a drawable resource name instead of an image path, for example `"largeIcon": "@drawable/my_icon"`.** Smaller, but the user must place the drawable in `android/` by hand, and prebuild regenerates that directory in both the managed and the bare workflow. Rejected: the file would not survive the next prebuild. 4. **Add a `largeIcon` property that generates the drawables and writes the meta-data, mirroring the existing `icon` path.** Chosen: it reuses the small icon's own generate-and-write code, it is symmetric with `icon` for users, and the prebuild output is byte-identical for projects that do not set it and do not write the key themselves.
--------- Co-authored-by: expo-bot --- .../unversioned/sdk/notifications.mdx | 7 ++ packages/expo-notifications/CHANGELOG.md | 1 + .../withNotificationsAndroid-test.ts | 36 +++++++- .../plugin/src/withNotifications.ts | 7 ++ .../plugin/src/withNotificationsAndroid.ts | 86 +++++++++++++++---- 5 files changed, 119 insertions(+), 18 deletions(-) diff --git a/docs/pages/versions/unversioned/sdk/notifications.mdx b/docs/pages/versions/unversioned/sdk/notifications.mdx index 1ea576e19fe0b1..f2cd68ed01ff41 100644 --- a/docs/pages/versions/unversioned/sdk/notifications.mdx +++ b/docs/pages/versions/unversioned/sdk/notifications.mdx @@ -364,6 +364,12 @@ To configure `expo-notifications`, use the built-in [config plugin](/config-plug description: 'Local path to an image to use as the icon for push notifications. 96x96 all-white png with transparency.', }, + { + name: 'largeIcon', + platform: 'android', + description: + 'Local path to an image to use as the large icon for notifications. The image is resized to 64x64 dp and shown next to the notification text. A notification that carries its own image uses that image instead.', + }, { name: 'color', default: '#ffffff', @@ -401,6 +407,7 @@ Here is an example of using the config plugin in the app config file: "expo-notifications", { "icon": "./local/assets/notification_icon.png", + "largeIcon": "./local/assets/notification_large_icon.png", "color": "#ffffff", "defaultChannel": "default", "sounds": [ diff --git a/packages/expo-notifications/CHANGELOG.md b/packages/expo-notifications/CHANGELOG.md index c54d13eab2cc80..44b144e3685dfe 100644 --- a/packages/expo-notifications/CHANGELOG.md +++ b/packages/expo-notifications/CHANGELOG.md @@ -10,6 +10,7 @@ - [ios] Forward notification center calls to a `UNUserNotificationCenterDelegate` that another library set, so that both libraries keep working. ([#48313](https://github.com/expo/expo/pull/48313) by [@vonovak](https://github.com/vonovak)) - [ios] Add support for grouping notifications via `threadIdentifier`. ([#49429](https://github.com/expo/expo/pull/49429) by [@vonovak](https://github.com/vonovak)) +- [Android] Add a `largeIcon` config plugin property that sets the notification large icon. ([#49481](https://github.com/expo/expo/pull/49481) by [@expo-bot](https://github.com/expo-bot)) ### ๐Ÿ› Bug fixes diff --git a/packages/expo-notifications/plugin/src/__tests__/withNotificationsAndroid-test.ts b/packages/expo-notifications/plugin/src/__tests__/withNotificationsAndroid-test.ts index d8201dfcb7ca63..1574d0889d3a08 100644 --- a/packages/expo-notifications/plugin/src/__tests__/withNotificationsAndroid-test.ts +++ b/packages/expo-notifications/plugin/src/__tests__/withNotificationsAndroid-test.ts @@ -1,7 +1,11 @@ import { fs, vol } from 'memfs'; import * as path from 'path'; -import { setNotificationIconAsync, setNotificationSounds } from '../withNotificationsAndroid'; +import { + setNotificationIconAsync, + setNotificationLargeIconAsync, + setNotificationSounds, +} from '../withNotificationsAndroid'; export function getDirFromFS(fsJSON: Record, rootDir: string) { return Object.entries(fsJSON) @@ -40,6 +44,17 @@ const LIST_OF_GENERATED_NOTIFICATION_FILES = [ 'android/app/src/main/res/raw/notification_sound.wav', ]; +const LIST_OF_GENERATED_LARGE_ICON_FILES = [ + 'android/app/src/main/res/drawable-mdpi/notification_large_icon.png', + 'android/app/src/main/res/drawable-hdpi/notification_large_icon.png', + 'android/app/src/main/res/drawable-xhdpi/notification_large_icon.png', + 'android/app/src/main/res/drawable-xxhdpi/notification_large_icon.png', + 'android/app/src/main/res/drawable-xxxhdpi/notification_large_icon.png', + 'android/app/src/main/res/values/colors.xml', + 'assets/notificationIcon.png', + 'assets/notification_sound.wav', +]; + const iconPath = path.resolve(__dirname, './fixtures/icon.png'); const soundPath = path.resolve(__dirname, './fixtures/cat.wav'); @@ -76,6 +91,25 @@ describe('Android notifications configuration', () => { expect(Object.keys(after).sort()).toEqual(LIST_OF_GENERATED_NOTIFICATION_FILES.sort()); }); + it('writes the large icon files as expected', async () => { + await setNotificationLargeIconAsync(projectRoot, '/app/assets/notificationIcon.png'); + + const after = getDirFromFS(vol.toJSON(), projectRoot); + expect(Object.keys(after).sort()).toEqual(LIST_OF_GENERATED_LARGE_ICON_FILES.sort()); + }); + + it('Safely remove the large icon if it exists, and ignore if it doesnt', async () => { + const before = getDirFromFS(vol.toJSON(), projectRoot); + await setNotificationLargeIconAsync(projectRoot, '/app/assets/notificationIcon.png'); + + await setNotificationLargeIconAsync(projectRoot, null); + expect(getDirFromFS(vol.toJSON(), projectRoot)).toMatchObject(before); + + // now remove again to make sure we don't throw in that case + await setNotificationLargeIconAsync(projectRoot, null); + expect(getDirFromFS(vol.toJSON(), projectRoot)).toMatchObject(before); + }); + it('Safely remove icon if it exists, and ignore if it doesnt', async () => { const before = getDirFromFS(vol.toJSON(), projectRoot); // first set the icon diff --git a/packages/expo-notifications/plugin/src/withNotifications.ts b/packages/expo-notifications/plugin/src/withNotifications.ts index c4cd1cb4af9c91..19afac29f9a9cc 100644 --- a/packages/expo-notifications/plugin/src/withNotifications.ts +++ b/packages/expo-notifications/plugin/src/withNotifications.ts @@ -13,6 +13,13 @@ export type NotificationsPluginProps = { * @platform android */ icon?: string; + /** + * Local path to an image to use as the large icon for notifications. The image is resized to + * 64x64 dp and shown next to the notification text. A notification that carries its own image + * uses that image instead. + * @platform android + */ + largeIcon?: string; /** * Tint color for the push notification image when it appears in the notification tray. * @default '#ffffff' diff --git a/packages/expo-notifications/plugin/src/withNotificationsAndroid.ts b/packages/expo-notifications/plugin/src/withNotificationsAndroid.ts index f01d72b4c6a895..d4da308d6c9257 100644 --- a/packages/expo-notifications/plugin/src/withNotificationsAndroid.ts +++ b/packages/expo-notifications/plugin/src/withNotificationsAndroid.ts @@ -31,6 +31,8 @@ const { removeMetaDataItemFromMainApplication, } = AndroidConfig.Manifest; const BASELINE_PIXEL_SIZE = 24; +// Matches Android's own `notification_large_icon_width`/`notification_large_icon_height` of 64dp. +const BASELINE_LARGE_ICON_PIXEL_SIZE = 64; const ERROR_MSG_PREFIX = 'An error occurred while configuring Android notifications. '; export const META_DATA_FCM_NOTIFICATION_ICON = @@ -44,20 +46,25 @@ export const META_DATA_LOCAL_NOTIFICATION_ICON = 'expo.modules.notifications.default_notification_icon'; export const META_DATA_LOCAL_NOTIFICATION_ICON_COLOR = 'expo.modules.notifications.default_notification_color'; - -// TODO @vonovak add config for local notification large icon -// expo.modules.notifications.large_notification_icon +export const META_DATA_LOCAL_NOTIFICATION_LARGE_ICON = + 'expo.modules.notifications.large_notification_icon'; export const NOTIFICATION_ICON = 'notification_icon'; export const NOTIFICATION_ICON_RESOURCE = `@drawable/${NOTIFICATION_ICON}`; +export const NOTIFICATION_LARGE_ICON = 'notification_large_icon'; +export const NOTIFICATION_LARGE_ICON_RESOURCE = `@drawable/${NOTIFICATION_LARGE_ICON}`; export const NOTIFICATION_ICON_COLOR = 'notification_icon_color'; export const NOTIFICATION_ICON_COLOR_RESOURCE = `@color/${NOTIFICATION_ICON_COLOR}`; -export const withNotificationIcons: ConfigPlugin<{ icon: string | null }> = (config, { icon }) => { +export const withNotificationIcons: ConfigPlugin<{ + icon: string | null; + largeIcon: string | null; +}> = (config, { icon, largeIcon }) => { return withDangerousMod(config, [ 'android', async (config) => { await setNotificationIconAsync(config.modRequest.projectRoot, icon); + await setNotificationLargeIconAsync(config.modRequest.projectRoot, largeIcon); return config; }, ]); @@ -76,11 +83,15 @@ export const withNotificationIconColor: ConfigPlugin<{ color: string | null }> = export const withNotificationManifest: ConfigPlugin<{ icon: string | null; + largeIcon: string | null; color: string | null; defaultChannel: string | null; -}> = (config, { icon, color, defaultChannel }) => { +}> = (config, { icon, largeIcon, color, defaultChannel }) => { return withAndroidManifest(config, (config) => { - config.modResults = setNotificationConfig({ icon, color, defaultChannel }, config.modResults); + config.modResults = setNotificationConfig( + { icon, largeIcon, color, defaultChannel }, + config.modResults + ); return config; }); }; @@ -109,15 +120,41 @@ export function setNotificationIconColor( * Applies notification icon configuration for expo-notifications */ export async function setNotificationIconAsync(projectRoot: string, icon: string | null) { + await setDrawableIconAsync(projectRoot, icon, NOTIFICATION_ICON, BASELINE_PIXEL_SIZE); +} + +/** + * Applies notification large icon configuration for expo-notifications + */ +export async function setNotificationLargeIconAsync(projectRoot: string, largeIcon: string | null) { + await setDrawableIconAsync( + projectRoot, + largeIcon, + NOTIFICATION_LARGE_ICON, + BASELINE_LARGE_ICON_PIXEL_SIZE + ); +} + +async function setDrawableIconAsync( + projectRoot: string, + icon: string | null, + resourceName: string, + baselinePixelSize: number +) { if (icon) { - await writeNotificationIconImageFilesAsync(icon, projectRoot); + await writeNotificationIconImageFilesAsync(icon, projectRoot, resourceName, baselinePixelSize); } else { - removeNotificationIconImageFiles(projectRoot); + removeNotificationIconImageFiles(projectRoot, resourceName); } } function setNotificationConfig( - props: { icon: string | null; color: string | null; defaultChannel?: string | null }, + props: { + icon: string | null; + largeIcon?: string | null; + color: string | null; + defaultChannel?: string | null; + }, manifest: AndroidConfig.Manifest.AndroidManifest ) { const mainApplication = getMainApplicationOrThrow(manifest); @@ -138,6 +175,16 @@ function setNotificationConfig( removeMetaDataItemFromMainApplication(mainApplication, META_DATA_FCM_NOTIFICATION_ICON); removeMetaDataItemFromMainApplication(mainApplication, META_DATA_LOCAL_NOTIFICATION_ICON); } + if (props.largeIcon) { + addMetaDataItemToMainApplication( + mainApplication, + META_DATA_LOCAL_NOTIFICATION_LARGE_ICON, + NOTIFICATION_LARGE_ICON_RESOURCE, + 'resource' + ); + } else { + removeMetaDataItemFromMainApplication(mainApplication, META_DATA_LOCAL_NOTIFICATION_LARGE_ICON); + } if (props.color) { addMetaDataItemToMainApplication( mainApplication, @@ -172,7 +219,12 @@ function setNotificationConfig( return manifest; } -async function writeNotificationIconImageFilesAsync(icon: string, projectRoot: string) { +async function writeNotificationIconImageFilesAsync( + icon: string, + projectRoot: string, + resourceName: string, + baselinePixelSize: number +) { await Promise.all( Object.values(dpiValues).map(async ({ folderName, scale }) => { const drawableFolderName = folderName.replace('mipmap', 'drawable'); @@ -180,7 +232,7 @@ async function writeNotificationIconImageFilesAsync(icon: string, projectRoot: s if (!existsSync(dpiFolderPath)) { mkdirSync(dpiFolderPath, { recursive: true }); } - const iconSizePx = BASELINE_PIXEL_SIZE * scale; + const iconSizePx = baselinePixelSize * scale; try { const resizedIcon = ( @@ -195,7 +247,7 @@ async function writeNotificationIconImageFilesAsync(icon: string, projectRoot: s } ) ).source; - writeFileSync(resolve(dpiFolderPath, NOTIFICATION_ICON + '.png'), resizedIcon); + writeFileSync(resolve(dpiFolderPath, resourceName + '.png'), resizedIcon); } catch (e) { throw new Error( ERROR_MSG_PREFIX + 'Encountered an issue resizing Android notification icon: ' + e @@ -205,11 +257,11 @@ async function writeNotificationIconImageFilesAsync(icon: string, projectRoot: s ); } -function removeNotificationIconImageFiles(projectRoot: string) { +function removeNotificationIconImageFiles(projectRoot: string, resourceName: string) { Object.values(dpiValues).forEach(async ({ folderName }) => { const drawableFolderName = folderName.replace('mipmap', 'drawable'); const dpiFolderPath = resolve(projectRoot, ANDROID_RES_PATH, drawableFolderName); - const iconFile = resolve(dpiFolderPath, NOTIFICATION_ICON + '.png'); + const iconFile = resolve(dpiFolderPath, resourceName + '.png'); if (existsSync(iconFile)) { unlinkSync(iconFile); } @@ -260,11 +312,11 @@ function writeNotificationSoundFile(soundFileRelativePath: string, projectRoot: export const withNotificationsAndroid: ConfigPlugin = ( config, - { icon = null, color = null, sounds = [], defaultChannel = null } + { icon = null, largeIcon = null, color = null, sounds = [], defaultChannel = null } ) => { config = withNotificationIconColor(config, { color }); - config = withNotificationIcons(config, { icon }); - config = withNotificationManifest(config, { icon, color, defaultChannel }); + config = withNotificationIcons(config, { icon, largeIcon }); + config = withNotificationManifest(config, { icon, largeIcon, color, defaultChannel }); config = withNotificationSounds(config, { sounds }); return config; }; From 5bae16c2a7c4503ab7c7a12806287b87bd6858df Mon Sep 17 00:00:00 2001 From: Jakub Tkacz <32908614+Ubax@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:44:26 +0200 Subject: [PATCH 5/8] [router] Replace imperative state reads with in-tree context (#49433) # Why In many places we still read `getState` or `store.state` instead of relying on the global state passed to navigators. # How 1. Remove `getState` and `getStateForKey`, and use `RootNavigationStateContext` and `NavigatorStateContext` instead 2. Replace usages of `store.state` by `RootNavigationStateContext` # Test Plan CI # Checklist - [ ] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [ ] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). - [ ] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) --- packages/expo-router/CHANGELOG.md | 1 + .../src/__tests__/headless-tabs.test.ios.tsx | 8 + .../src/fork/NavigationContainer.tsx | 15 -- .../useNavigationTreeReducer.test.ios.tsx | 20 --- .../global-state/useNavigationTreeReducer.ts | 10 +- .../useRootNavigationState.test.ios.tsx | 28 ++++ .../src/hooks/useRootNavigationState.ts | 16 +- .../src/link/preview/HrefPreview.tsx | 6 +- .../usePreventZoomTransitionDismissal.ios.tsx | 4 +- .../core/BaseNavigationContainer.tsx | 51 +++---- .../core/NavigationBuilderContext.tsx | 4 +- .../core/NavigationProvider.tsx | 12 +- .../core/RootNavigationStateContext.tsx | 7 + .../src/react-navigation/core/SceneView.tsx | 1 - .../BaseNavigationContainer.test.ios.tsx | 60 ++++++++ .../__tests__/useEventEmitter.test.ios.tsx | 6 +- .../core/__tests__/useIsFocused.test.ios.tsx | 33 ++++- .../__tests__/useNavigationCache.test.ios.tsx | 30 ++-- .../react-navigation/core/useDescriptors.tsx | 24 +-- .../react-navigation/core/useIsFocused.tsx | 11 ++ .../core/useNavigationBuilder.tsx | 37 +---- .../core/useNavigationCache.tsx | 31 ++-- .../core/useNavigationHelpers.tsx | 9 +- .../core/useOnPreventRemove.tsx | 10 +- .../core/useOptionsGetters.tsx | 21 +-- .../core/usePreventRemoveState.tsx | 13 +- .../__tests__/useLinkBuilder.test.ios.tsx | 19 ++- .../__tests__/useScrollToTop.test.ios.tsx | 138 ++++++++++++++++++ .../native/useLinkBuilder.tsx | 13 +- .../native/useScrollToTop.tsx | 97 +++++------- packages/expo-router/src/ui/TabContext.tsx | 4 + packages/expo-router/src/ui/TabTrigger.tsx | 16 +- packages/expo-router/src/ui/Tabs.tsx | 15 +- 33 files changed, 477 insertions(+), 293 deletions(-) create mode 100644 packages/expo-router/src/react-navigation/core/RootNavigationStateContext.tsx create mode 100644 packages/expo-router/src/react-navigation/native/__tests__/useScrollToTop.test.ios.tsx diff --git a/packages/expo-router/CHANGELOG.md b/packages/expo-router/CHANGELOG.md index a9423ee503395a..90372c23c64965 100644 --- a/packages/expo-router/CHANGELOG.md +++ b/packages/expo-router/CHANGELOG.md @@ -80,6 +80,7 @@ ### ๐Ÿ’ก Others +- Read navigation state from the React tree instead of imperative refs. ([#49433](https://github.com/expo/expo/pull/49433) by [@Ubax](https://github.com/Ubax)) - Scope routing queues to each router root and bind `useRouter()` to its owning container. ([#49351](https://github.com/expo/expo/pull/49351) by [@Ubax](https://github.com/Ubax)) - Derive `useIsFocused` from context. ([#49390](https://github.com/expo/expo/pull/49390) by [@Ubax](https://github.com/Ubax)) - Base `useNavigationState` on global state. ([#49381](https://github.com/expo/expo/pull/49381) by [@jakub-agent](https://github.com/jakub-agent)) diff --git a/packages/expo-router/src/__tests__/headless-tabs.test.ios.tsx b/packages/expo-router/src/__tests__/headless-tabs.test.ios.tsx index 08dbcb7e9aedf0..25017c3a7ab7d7 100644 --- a/packages/expo-router/src/__tests__/headless-tabs.test.ios.tsx +++ b/packages/expo-router/src/__tests__/headless-tabs.test.ios.tsx @@ -1190,6 +1190,14 @@ it('can reference a parent trigger from nested tabs', () => { expect(screen.getByTestId('current-parent')).toHaveProp('isFocused', true); expect(screen.getByTestId('goto-parent')).toHaveProp('isFocused', false); fireEvent.press(screen.getByTestId('goto-parent')); + expect(screen.getByTestId('current-parent', { includeHiddenElements: true })).toHaveProp( + 'isFocused', + false + ); + expect(screen.getByTestId('goto-parent', { includeHiddenElements: true })).toHaveProp( + 'isFocused', + true + ); expect(screen.getByTestId('index')).toBeVisible(); }); diff --git a/packages/expo-router/src/fork/NavigationContainer.tsx b/packages/expo-router/src/fork/NavigationContainer.tsx index 6a1c086fe8a8a4..90260b3d245818 100644 --- a/packages/expo-router/src/fork/NavigationContainer.tsx +++ b/packages/expo-router/src/fork/NavigationContainer.tsx @@ -147,21 +147,6 @@ function NavigationContainerInner( }); const [isResolved, initialState] = useThenable(getInitialState); - if ( - store && - // Linking state remains the initial state forever. Once navigation is ready, - // `onStateChange` owns the store and this must not restore stale state. - !refContainer.current?.isReady() && - // Async linking may not have produced its initial state yet. - initialState && - // Avoid recalculating route info when the store already has this exact state. - initialState !== store.state - ) { - // TODO(@ubax): remove this render-phase global write with store ownership teardown. - // https://linear.app/expo/issue/ENG-26124 - // Children read route info during this render, so an effect would update the store too late. - syncStoreNavigationState(initialState); - } React.useImperativeHandle(ref, () => refContainer.current!); diff --git a/packages/expo-router/src/global-state/__tests__/useNavigationTreeReducer.test.ios.tsx b/packages/expo-router/src/global-state/__tests__/useNavigationTreeReducer.test.ios.tsx index 93f1e0552fa047..06dbf81aa297cd 100644 --- a/packages/expo-router/src/global-state/__tests__/useNavigationTreeReducer.test.ios.tsx +++ b/packages/expo-router/src/global-state/__tests__/useNavigationTreeReducer.test.ios.tsx @@ -303,23 +303,3 @@ it('throws for an incomplete initial state', () => { ) ).toThrow('incomplete initial state'); }); - -it('reads the latest root state by key', () => { - const result = renderReducer({ - registry: new Map([ - [ - 'root', - entry((state) => ({ - state: { ...state, index: 1 }, - affectedRouteKey: state.routes[1]!.key, - })), - ], - ]), - }); - - act(() => result.result.current.handleAction({ type: 'NEXT' })); - - expect(result.result.current.getState()).toBe(result.result.current.state); - expect(result.result.current.getStateForKey('root')).toBe(result.result.current.state); - expect(result.result.current.getStateForKey('missing')).toBeUndefined(); -}); diff --git a/packages/expo-router/src/global-state/useNavigationTreeReducer.ts b/packages/expo-router/src/global-state/useNavigationTreeReducer.ts index f490f843d7c44f..a08a9b06b2921b 100644 --- a/packages/expo-router/src/global-state/useNavigationTreeReducer.ts +++ b/packages/expo-router/src/global-state/useNavigationTreeReducer.ts @@ -221,7 +221,6 @@ export function useNavigationTreeReducer({ () => ({ registry, routeNode, linking, redirects }), [registry, routeNode, linking, redirects] ); - const stateRef = React.useRef(state); const previousRegistryRef = React.useRef(registry); const processAction = React.useCallback( @@ -246,17 +245,12 @@ export function useNavigationTreeReducer({ warnIfScreenParam(params); process({ type: 'ACTION', payload: { action, originKey } }); }); - const getState = React.useCallback(() => stateRef.current, []); - const getStateForKey = React.useCallback( - (key: string) => findStateByKey(stateRef.current, key), - [] - ); const resetNavigator = useLatestCallback((stateKey: string, routerType: string | undefined) => { process({ type: 'NAVIGATOR_CHANGED', stateKey, routerType }); }); React.useInsertionEffect(() => { - stateRef.current = state; + // TODO(@ubax): Check if this is still needed onStateChangeInsertion?.(state); }, [onStateChangeInsertion, state]); @@ -272,8 +266,6 @@ export function useNavigationTreeReducer({ return { state, - getState, - getStateForKey, resetNavigator, handleAction, processIntent, diff --git a/packages/expo-router/src/hooks/__tests__/useRootNavigationState.test.ios.tsx b/packages/expo-router/src/hooks/__tests__/useRootNavigationState.test.ios.tsx index dae33cde49321b..9cf217cadb7a1a 100644 --- a/packages/expo-router/src/hooks/__tests__/useRootNavigationState.test.ios.tsx +++ b/packages/expo-router/src/hooks/__tests__/useRootNavigationState.test.ios.tsx @@ -1,5 +1,7 @@ +import { act, renderHook as renderNativeHook } from '@testing-library/react-native'; import { Text } from 'react-native'; +import { router } from '../../imperative-api'; import Stack from '../../layouts/Stack'; import Tabs from '../../layouts/Tabs'; import { renderRouter } from '../../testing-library'; @@ -7,6 +9,32 @@ import { useRootNavigationState } from '../useRootNavigationState'; import { renderHook } from './renderHook'; describe(useRootNavigationState, () => { + it('throws outside a navigation container', () => { + expect(() => renderNativeHook(() => useRootNavigationState())).toThrow( + 'useRootNavigationState was called from a generated route. This is likely a bug in Expo Router.' + ); + }); + + it('returns the updated root state after navigation', () => { + const states: ReturnType[] = []; + + renderRouter({ + _layout: () => , + index: function Index() { + states.push(useRootNavigationState()); + return Index; + }, + second: () => Second, + }); + + const initialState = states[states.length - 1]; + + act(() => router.push('/second')); + + expect(states[states.length - 1]).not.toBe(initialState); + expect(states[states.length - 1]?.routes[0]?.state?.routes.at(-1)?.name).toBe('second'); + }); + it('returns the root navigation state', () => { const { result } = renderHook(() => useRootNavigationState(), ['index'], { initialUrl: '/?test=1&test=2', diff --git a/packages/expo-router/src/hooks/useRootNavigationState.ts b/packages/expo-router/src/hooks/useRootNavigationState.ts index b7036cf23c2229..9540f285dd8beb 100644 --- a/packages/expo-router/src/hooks/useRootNavigationState.ts +++ b/packages/expo-router/src/hooks/useRootNavigationState.ts @@ -1,8 +1,9 @@ 'use client'; -import { INTERNAL_SLOT_NAME } from '../constants'; -import type { NavigationProp, NavigationState } from '../react-navigation/native'; -import { useNavigation } from '../react-navigation/native'; +import { use } from 'react'; + +import { RootNavigationStateContext } from '../react-navigation/core/RootNavigationStateContext'; +import type { NavigationState } from '../react-navigation/native'; /** * Returns the navigation state of the root navigator โ€” the top-level navigator that @@ -25,14 +26,11 @@ import { useNavigation } from '../react-navigation/native'; * reference for the shape of the returned object. */ export function useRootNavigationState(): NavigationState { - const parent = - // We assume that this is called from routes in __root - // Users cannot customize the generated Sitemap or NotFound routes, so we should be safe - useNavigation>().getParent(INTERNAL_SLOT_NAME); - if (!parent) { + const state = use(RootNavigationStateContext); + if (state === undefined) { throw new Error( 'useRootNavigationState was called from a generated route. This is likely a bug in Expo Router.' ); } - return parent.getState(); + return state; } diff --git a/packages/expo-router/src/link/preview/HrefPreview.tsx b/packages/expo-router/src/link/preview/HrefPreview.tsx index e6310359773742..eacc985fe65350 100644 --- a/packages/expo-router/src/link/preview/HrefPreview.tsx +++ b/packages/expo-router/src/link/preview/HrefPreview.tsx @@ -7,11 +7,12 @@ import { findRouteNodeAndParamsForState, type RouteNode } from '../../Route'; import { INTERNAL_SLOT_NAME } from '../../constants'; import type { ResultState } from '../../exports'; import { CompositionContext } from '../../fork/native-stack/composition-options'; -import { store } from '../../global-state/router-store'; import { StoreContext } from '../../global-state/storeContext'; +import type { ReactNavigationState } from '../../global-state/types'; import { useRouteInfo } from '../../global-state/useRouteInfo'; import { getRootStackRouteNames } from '../../global-state/utils'; import { usePathname } from '../../hooks'; +import { RootNavigationStateContext } from '../../react-navigation/core/RootNavigationStateContext'; import { NavigationContext, type NavigationProp, @@ -28,6 +29,7 @@ export function HrefPreview({ href }: { href: Href }) { // TODO(@ubax): Extract `linking` and `routeNode` into separate contexts to avoid unrelated rerenders. const { segments: routeSegments } = useRouteInfo(); const { linking, routeNode } = use(StoreContext) ?? {}; + const rootNavigationState = use(RootNavigationStateContext); const hrefState = useMemo( () => getStateForHref(href, { segments: routeSegments }, linking), [href, routeSegments, linking] @@ -37,7 +39,7 @@ export function HrefPreview({ href }: { href: Href }) { let isProtected = false; if (hrefState?.routes[index]?.name === INTERNAL_SLOT_NAME) { let routerState: typeof hrefState | undefined = hrefState; - let rnState = store.state; + let rnState: ReactNavigationState | undefined = rootNavigationState; while (routerState && rnState) { const routerRoute: ResultState['routes'][number] = routerState.routes[0]!; // When the route we want to show is not present in react-navigation state diff --git a/packages/expo-router/src/link/zoom/usePreventZoomTransitionDismissal.ios.tsx b/packages/expo-router/src/link/zoom/usePreventZoomTransitionDismissal.ios.tsx index 0b106ff8561185..ebf8f59a189037 100644 --- a/packages/expo-router/src/link/zoom/usePreventZoomTransitionDismissal.ios.tsx +++ b/packages/expo-router/src/link/zoom/usePreventZoomTransitionDismissal.ios.tsx @@ -4,6 +4,7 @@ import { use } from 'react'; import { DescriptorsContext } from '../../fork/native-stack/descriptors-context'; import { INTERNAL_EXPO_ROUTER_GESTURE_ENABLED_OPTION_NAME } from '../../navigationParams'; +import { NavigatorStateContext } from '../../react-navigation/core/useNavigationState'; import { useRoute } from '../../react-navigation/native'; import { useNavigation } from '../../useNavigation'; import { isRoutePreloadedInStack } from '../../utils/stack'; @@ -19,9 +20,10 @@ export function usePreventZoomTransitionDismissal( const context = use(ZoomTransitionTargetContext); const route = useRoute(); const navigation = useNavigation(); + const navigatorState = use(NavigatorStateContext); const isPreview = useIsPreview(); const isFocused = navigation.isFocused(); - const isPreloaded = isPreview ? false : isRoutePreloadedInStack(navigation.getState(), route); + const isPreloaded = isPreview ? false : isRoutePreloadedInStack(navigatorState, route); const descriptorsMap = use(DescriptorsContext); const currentDescriptor = descriptorsMap[route.key]; diff --git a/packages/expo-router/src/react-navigation/core/BaseNavigationContainer.tsx b/packages/expo-router/src/react-navigation/core/BaseNavigationContainer.tsx index 3f95430fa189f5..c0c746c8e0c4c3 100644 --- a/packages/expo-router/src/react-navigation/core/BaseNavigationContainer.tsx +++ b/packages/expo-router/src/react-navigation/core/BaseNavigationContainer.tsx @@ -27,6 +27,7 @@ import { EnsureSingleNavigator } from './EnsureSingleNavigator'; import { NavigationBuilderContext } from './NavigationBuilderContext'; import { NavigationContainerRefContext } from './NavigationContainerRefContext'; import { NavigationStateContext } from './NavigationStateContext'; +import { RootNavigationStateContext } from './RootNavigationStateContext'; import { checkDuplicateRouteNames } from './checkDuplicateRouteNames'; import { checkSerializable } from './checkSerializable'; import { NOT_INITIALIZED_ERROR } from './createNavigationContainerRef'; @@ -113,15 +114,14 @@ function BaseNavigationContainerInner({ }); // TODO(@ubax): consider moving this state to ExpoRoot. - const { state, getState, getStateForKey, resetNavigator, handleAction, processIntent } = - useNavigationTreeReducer({ - initialState, - routeNode: UNSTABLE_routeNode, - registry, - linking: store?.linking, - redirects: store?.redirects, - onStateChangeInsertion: UNSTABLE_onStateChangeInsertion, - }); + const { state, resetNavigator, handleAction, processIntent } = useNavigationTreeReducer({ + initialState, + routeNode: UNSTABLE_routeNode, + registry, + linking: store?.linking, + redirects: store?.redirects, + onStateChangeInsertion: UNSTABLE_onStateChangeInsertion, + }); const hasNotifiedInitialStateRef = React.useRef(false); const lastNotifiedStateRef = React.useRef(undefined); @@ -156,9 +156,7 @@ function BaseNavigationContainerInner({ } }); - const getRootState = useLatestCallback(() => { - return getState(); - }); + const getRootState = useLatestCallback(() => state); const getCurrentRoute = useLatestCallback(() => { const state = getRootState(); @@ -172,9 +170,8 @@ function BaseNavigationContainerInner({ return route as Route | undefined; }); - const isReady = useLatestCallback( - () => listeners.focus[0] != null && registry.has(getState().key) - ); + // TODO(@ubax): check if this is still needed anywhere + const isReady = useLatestCallback(() => listeners.focus[0] != null && registry.has(state.key)); const { addOptionsGetter, getCurrentOptions } = useOptionsGetters({}); @@ -192,7 +189,7 @@ function BaseNavigationContainerInner({ isFocused: () => true, canGoBack, getParent: () => undefined, - getState, + getState: getRootState, getRootState, getCurrentRoute, getCurrentOptions, @@ -209,7 +206,6 @@ function BaseNavigationContainerInner({ getCurrentOptions, getCurrentRoute, getRootState, - getState, isReady, ] ); @@ -244,21 +240,12 @@ function BaseNavigationContainerInner({ addListener, addKeyedListener, handleAction, - getStateForKey, resetNavigator, onDispatchAction, onOptionsChange, stackRef, }), - [ - addListener, - addKeyedListener, - getStateForKey, - handleAction, - onDispatchAction, - onOptionsChange, - resetNavigator, - ] + [addListener, addKeyedListener, handleAction, onDispatchAction, onOptionsChange, resetNavigator] ); const context = React.useMemo( @@ -388,10 +375,12 @@ function BaseNavigationContainerInner({ - - {children} - - + + + {children} + + + diff --git a/packages/expo-router/src/react-navigation/core/NavigationBuilderContext.tsx b/packages/expo-router/src/react-navigation/core/NavigationBuilderContext.tsx index 9a002bba4367aa..8ee510922ffce7 100644 --- a/packages/expo-router/src/react-navigation/core/NavigationBuilderContext.tsx +++ b/packages/expo-router/src/react-navigation/core/NavigationBuilderContext.tsx @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; -import type { NavigationAction, NavigationState, ParamListBase } from '../routers'; +import type { NavigationAction, ParamListBase } from '../routers'; import type { NavigationHelpers } from './types'; export type ListenerMap = { @@ -37,7 +37,6 @@ export type ChildBeforeRemoveListener = (action: NavigationAction) => void; */ export const NavigationBuilderContext = React.createContext<{ handleAction: (action: NavigationAction, originKey?: string) => void; - getStateForKey: (key: string) => NavigationState | undefined; resetNavigator: (stateKey: string, routerType: string | undefined) => void; addListener?: AddListener; addKeyedListener?: AddKeyedListener; @@ -46,7 +45,6 @@ export const NavigationBuilderContext = React.createContext<{ stackRef?: React.MutableRefObject; }>({ handleAction: () => undefined, - getStateForKey: () => undefined, resetNavigator: () => undefined, onDispatchAction: () => undefined, onOptionsChange: () => undefined, diff --git a/packages/expo-router/src/react-navigation/core/NavigationProvider.tsx b/packages/expo-router/src/react-navigation/core/NavigationProvider.tsx index 1c85ed85276cdd..8ad0e1607348f3 100644 --- a/packages/expo-router/src/react-navigation/core/NavigationProvider.tsx +++ b/packages/expo-router/src/react-navigation/core/NavigationProvider.tsx @@ -1,11 +1,10 @@ 'use client'; import * as React from 'react'; -import { use } from 'react'; import type { ParamListBase, Route } from '../routers'; import { NavigationContext } from './NavigationContext'; import type { NavigationProp } from './types'; -import { FocusedRouteKeyContext, IsFocusedContext } from './useIsFocused'; +import { IsFocusedContext, useIsRouteFocused } from './useIsFocused'; /** * Context which holds the route prop for a screen. @@ -26,14 +25,7 @@ export const NamedRouteContextListContext = React.createContext< >(undefined); export function NavigationProvider({ route, navigation, children }: Props) { - const parentIsFocused = use(IsFocusedContext); - const focusedRouteKey = use(FocusedRouteKeyContext); - - // Mark route as focused only if: - // - It doesn't have a parent navigator - // - Parent navigator is focused - const isFocused = - parentIsFocused == null || parentIsFocused ? focusedRouteKey === route.key : false; + const isFocused = useIsRouteFocused(route.key); return ( diff --git a/packages/expo-router/src/react-navigation/core/RootNavigationStateContext.tsx b/packages/expo-router/src/react-navigation/core/RootNavigationStateContext.tsx new file mode 100644 index 00000000000000..e42d0538513a26 --- /dev/null +++ b/packages/expo-router/src/react-navigation/core/RootNavigationStateContext.tsx @@ -0,0 +1,7 @@ +'use client'; + +import { createContext } from 'react'; + +import type { NavigationState } from '../routers'; + +export const RootNavigationStateContext = createContext(undefined); diff --git a/packages/expo-router/src/react-navigation/core/SceneView.tsx b/packages/expo-router/src/react-navigation/core/SceneView.tsx index d278ac34fd78a4..f08afcf47bf3af 100644 --- a/packages/expo-router/src/react-navigation/core/SceneView.tsx +++ b/packages/expo-router/src/react-navigation/core/SceneView.tsx @@ -37,7 +37,6 @@ export function SceneView { expect(ref.current?.getCurrentOptions()).toEqual({ h: 9 }); }); +test('does not emit options from an unfocused nested navigator', () => { + const NoFocusMockRouter = (options: DefaultRouterOptions) => ({ + ...MockRouter(options), + shouldActionChangeFocus: () => false, + }); + const TestNavigator = React.forwardRef(function TestNavigator(props: any, ref: any): any { + const { state, navigation, descriptors, NavigationContent } = useNavigationBuilder( + NoFocusMockRouter, + props + ); + + React.useImperativeHandle(ref, () => navigation, [navigation]); + + return ( + + {state.routes.map((route) => descriptors[route.key]!.render())} + + ); + }); + const child = React.createRef(); + const ref = createNavigationContainerRef(); + const listener = jest.fn(); + + render( + + + + {() => null} + + + {() => ( + + + {() => null} + + + {() => null} + + + )} + + + + ); + ref.current?.addListener('options', listener); + + act(() => child.current.navigate('fourth')); + + expect(ref.current?.getCurrentRoute()?.name).toBe('first'); + expect(listener).not.toHaveBeenCalled(); + expect(ref.current?.getCurrentOptions()).toEqual({ x: 1 }); +}); + test('emits option events when options change with stack router', () => { const TestNavigator = (props: any) => { const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); diff --git a/packages/expo-router/src/react-navigation/core/__tests__/useEventEmitter.test.ios.tsx b/packages/expo-router/src/react-navigation/core/__tests__/useEventEmitter.test.ios.tsx index 2b6572a43b15b1..2bc33cba36541d 100644 --- a/packages/expo-router/src/react-navigation/core/__tests__/useEventEmitter.test.ios.tsx +++ b/packages/expo-router/src/react-navigation/core/__tests__/useEventEmitter.test.ios.tsx @@ -274,7 +274,7 @@ test('fires focus and blur events in nested navigator', () => { act(() => parent.current.navigate('first')); expect(firstFocusCallback).toHaveBeenCalledTimes(2); - expect(thirdBlurCallback).toHaveBeenCalledTimes(2); + expect(thirdBlurCallback).toHaveBeenCalledTimes(1); act(() => { child.current.navigate('fourth'); @@ -282,7 +282,7 @@ test('fires focus and blur events in nested navigator', () => { }); expect(fourthFocusCallback).toHaveBeenCalledTimes(3); - expect(thirdBlurCallback).toHaveBeenCalledTimes(2); + expect(thirdBlurCallback).toHaveBeenCalledTimes(1); expect(firstBlurCallback).toHaveBeenCalledTimes(2); act(() => child.current.navigate('third')); @@ -298,7 +298,7 @@ test('fires focus and blur events in nested navigator', () => { expect(secondBlurCallback).toHaveBeenCalledTimes(1); expect(thirdFocusCallback).toHaveBeenCalledTimes(2); - expect(thirdBlurCallback).toHaveBeenCalledTimes(2); + expect(thirdBlurCallback).toHaveBeenCalledTimes(1); expect(fourthFocusCallback).toHaveBeenCalledTimes(3); expect(fourthBlurCallback).toHaveBeenCalledTimes(3); diff --git a/packages/expo-router/src/react-navigation/core/__tests__/useIsFocused.test.ios.tsx b/packages/expo-router/src/react-navigation/core/__tests__/useIsFocused.test.ios.tsx index 8bd065e6a0fc8c..b47511943a8430 100644 --- a/packages/expo-router/src/react-navigation/core/__tests__/useIsFocused.test.ios.tsx +++ b/packages/expo-router/src/react-navigation/core/__tests__/useIsFocused.test.ios.tsx @@ -1,10 +1,15 @@ -import { act, render } from '@testing-library/react-native'; +import { act, render, renderHook } from '@testing-library/react-native'; import * as React from 'react'; import type { ParamListBase } from '../../routers'; import { Screen } from '../Screen'; import { createNavigationContainerRef } from '../createNavigationContainerRef'; -import { IsFocusedContext, useIsFocused } from '../useIsFocused'; +import { + FocusedRouteKeyContext, + IsFocusedContext, + useIsFocused, + useIsRouteFocused, +} from '../useIsFocused'; import { useNavigationBuilder } from '../useNavigationBuilder'; import { useRoute } from '../useRoute'; import { BaseNavigationContainer } from './__fixtures__/BaseNavigationContainer'; @@ -41,6 +46,30 @@ test('throws without a focus context', () => { ); }); +test.each([ + { routeKey: undefined, parentIsFocused: undefined, focusedRouteKey: 'route', expected: true }, + { routeKey: undefined, parentIsFocused: false, focusedRouteKey: 'route', expected: false }, + { routeKey: undefined, parentIsFocused: true, focusedRouteKey: 'route', expected: true }, + { routeKey: 'route', parentIsFocused: undefined, focusedRouteKey: 'route', expected: true }, + { routeKey: 'route', parentIsFocused: true, focusedRouteKey: 'other', expected: false }, + { routeKey: 'route', parentIsFocused: false, focusedRouteKey: 'route', expected: false }, +])( + 'returns $expected for route $routeKey with parent focus $parentIsFocused and focused route $focusedRouteKey', + ({ routeKey, parentIsFocused, focusedRouteKey, expected }) => { + const wrapper = ({ children }: React.PropsWithChildren) => ( + + + {children} + + + ); + + const { result } = renderHook(() => useIsRouteFocused(routeKey), { wrapper }); + + expect(result.current).toBe(expected); + } +); + test('renders correct focus state', () => { const TestNavigator = (props: any): any => { const { state, descriptors, NavigationContent } = useNavigationBuilder(MockRouter, props); diff --git a/packages/expo-router/src/react-navigation/core/__tests__/useNavigationCache.test.ios.tsx b/packages/expo-router/src/react-navigation/core/__tests__/useNavigationCache.test.ios.tsx index 6a8ea80e8a1b99..9c96dac5d948f0 100644 --- a/packages/expo-router/src/react-navigation/core/__tests__/useNavigationCache.test.ios.tsx +++ b/packages/expo-router/src/react-navigation/core/__tests__/useNavigationCache.test.ios.tsx @@ -26,7 +26,7 @@ afterEach(() => { }); test('preserves reference for navigation objects', () => { - expect.assertions(2); + expect.assertions(4); const state: NavigationState = { type: 'tab', @@ -41,7 +41,6 @@ test('preserves reference for navigation objects', () => { ], }; - const getState = () => state; const navigation = {} as any; const setOptions = (() => {}) as any; const router = MockRouter({}); @@ -53,14 +52,16 @@ test('preserves reference for navigation objects', () => { const getNavigation = useNavigationCache({ routes: state.routes, routeNames: state.routeNames, - getState, navigation, setOptions, router, emitter, }); - const navigations = state.routes.map((route) => getNavigation(route)); + const navigations = state.routes.flatMap((route) => [ + getNavigation(route, false), + getNavigation(route, true), + ]); if (previous.current !== undefined) { navigations.forEach((navigation, index) => { expect(navigation).toBe(previous.current[index]); @@ -82,15 +83,6 @@ test('preserves reference for navigation objects', () => { test('preserves placeholder navigation after the route is created', () => { let routeNames = ['Foo', 'Bar']; let routes = [{ key: 'Foo-key', name: 'Foo' }]; - const getState = (): NavigationState => ({ - type: 'tab', - stale: false as const, - routeKeySeq: 0, - index: 0, - key: 'State', - routeNames, - routes, - }); const navigation = { getId: () => 'State', getParent: jest.fn(), @@ -104,7 +96,6 @@ test('preserves placeholder navigation after the route is created', () => { getNavigation = useNavigationCache({ routes, routeNames, - getState, navigation, setOptions, router, @@ -114,12 +105,12 @@ test('preserves placeholder navigation after the route is created', () => { }; const root = render(); - const placeholderNavigation = getNavigation!({ key: 'Bar', name: 'Bar' }); + const placeholderNavigation = getNavigation!({ key: 'Bar', name: 'Bar' }, false); routes = [...routes, { key: 'Bar-key', name: 'Bar' }]; root.update(); - expect(getNavigation!({ key: 'Bar', name: 'Bar' })).toBe(placeholderNavigation); + expect(getNavigation!({ key: 'Bar', name: 'Bar' }, false)).toBe(placeholderNavigation); routeNames = ['Foo']; routes = routes.filter((route) => route.name !== 'Bar'); @@ -283,7 +274,7 @@ test('returns correct value for isFocused after changing screens', () => { expect(navigation.isFocused()).toBe(false); }); -test('ignores dispatches from a preloaded stack screen until it is promoted', () => { +test('uses a no-op navigation object for a preloaded stack screen', () => { const TestNavigator = (props: any) => { const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); @@ -346,10 +337,11 @@ test('ignores dispatches from a preloaded stack screen until it is promoted', () act(() => ref.current?.navigate('second')); - expect(navigation).toBe(preloadedNavigation); + expect(navigation).not.toBe(preloadedNavigation); + const activeNavigation = navigation; enqueue.mockClear(); - act(() => preloadedNavigation.dispatch(CommonActions.goBack())); + act(() => activeNavigation.dispatch(CommonActions.goBack())); expect(enqueue).toHaveBeenCalledTimes(1); expect(enqueue).toHaveBeenCalledWith({ diff --git a/packages/expo-router/src/react-navigation/core/useDescriptors.tsx b/packages/expo-router/src/react-navigation/core/useDescriptors.tsx index 0197e6beb83258..06d5590b2ef387 100644 --- a/packages/expo-router/src/react-navigation/core/useDescriptors.tsx +++ b/packages/expo-router/src/react-navigation/core/useDescriptors.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import { use } from 'react'; +import { isRoutePreloadedInStack } from '../../utils/stack'; import type { NavigationAction, NavigationState, @@ -71,7 +72,7 @@ type Options< navigation: NavigationHelpers; screenOptions: ScreenOptionsOrCallback | undefined; screenLayout: ScreenLayout | undefined; - getState: () => State; + state: State; addListener: AddListener; addKeyedListener: AddKeyedListener; router: Router; @@ -99,7 +100,7 @@ export function useDescriptors< navigation, screenOptions, screenLayout, - getState, + state, addListener, addKeyedListener, router, @@ -107,20 +108,13 @@ export function useDescriptors< }: Options) { const theme = use(ThemeContext); const [options, setOptions] = React.useState>({}); - const { - handleAction, - getStateForKey, - resetNavigator, - onDispatchAction, - onOptionsChange, - stackRef, - } = use(NavigationBuilderContext); + const { handleAction, resetNavigator, onDispatchAction, onOptionsChange, stackRef } = + use(NavigationBuilderContext); const context = React.useMemo( () => ({ navigation, handleAction, - getStateForKey, resetNavigator, addListener, addKeyedListener, @@ -131,7 +125,6 @@ export function useDescriptors< [ navigation, handleAction, - getStateForKey, resetNavigator, addListener, addKeyedListener, @@ -144,7 +137,6 @@ export function useDescriptors< const getNavigation = useNavigationCache({ routes, routeNames, - getState, navigation, setOptions, router, @@ -270,7 +262,7 @@ export function useDescriptors< >; const descriptors = cachedRoutes.reduce((acc, route, i) => { - const navigation = getNavigation(route); + const navigation = getNavigation(route, isRoutePreloadedInStack(state, route)); if (screens[route.name] === undefined) { acc[route.key] = { @@ -318,13 +310,13 @@ export function useDescriptors< if (!config) { return { route, - navigation: getNavigation({ key: route.name, name: route.name }), + navigation: getNavigation({ key: route.name, name: route.name }, false), options: {} as ScreenOptions, render: () => null, } as DescriptorMap[string]; } - const navigation = getNavigation({ key: route.name, name: route.name }); + const navigation = getNavigation({ key: route.name, name: route.name }, false); return { route, navigation, diff --git a/packages/expo-router/src/react-navigation/core/useIsFocused.tsx b/packages/expo-router/src/react-navigation/core/useIsFocused.tsx index cb2984c1774721..f22bb04ee0e3d9 100644 --- a/packages/expo-router/src/react-navigation/core/useIsFocused.tsx +++ b/packages/expo-router/src/react-navigation/core/useIsFocused.tsx @@ -6,6 +6,17 @@ export const FocusedRouteKeyContext = React.createContext(un export const IsFocusedContext = React.createContext(undefined); +export function useIsRouteFocused(routeKey: string | undefined): boolean { + const parentIsFocused = use(IsFocusedContext); + const focusedRouteKey = use(FocusedRouteKeyContext); + + if (routeKey === undefined) { + return parentIsFocused ?? true; + } + + return parentIsFocused == null || parentIsFocused ? focusedRouteKey === routeKey : false; +} + /** * Hook to get the current focus state of the screen. Returns a `true` if screen is focused, otherwise `false`. * This can be used if a component needs to render something based on the focus state. diff --git a/packages/expo-router/src/react-navigation/core/useNavigationBuilder.tsx b/packages/expo-router/src/react-navigation/core/useNavigationBuilder.tsx index a443db791db535..f6f701d5507991 100644 --- a/packages/expo-router/src/react-navigation/core/useNavigationBuilder.tsx +++ b/packages/expo-router/src/react-navigation/core/useNavigationBuilder.tsx @@ -9,7 +9,6 @@ import { useComponent } from '../../fork/useComponent'; import { type RouterRegistryEntry, useRegisterRouter } from '../../global-state/routerRegistry'; import { useEnqueueRoutingIntent } from '../../global-state/routingQueueContext'; import { resetNavigatorState } from '../../global-state/stateUtils'; -import useLatestCallback from '../../utils/useLatestCallback'; import { type DefaultRouterOptions, type NavigationAction, @@ -263,7 +262,6 @@ export function useNavigationBuilder< useRegisterNavigator(); const routeNode = useRouteNode(); const enqueue = useEnqueueRoutingIntent(); - const { children, layout, @@ -333,7 +331,7 @@ export function useNavigationBuilder< const { state: currentState } = use(NavigationStateContext); - const { getStateForKey, resetNavigator, handleAction } = use(NavigationBuilderContext); + const { resetNavigator, handleAction } = use(NavigationBuilderContext); if ( currentState === undefined || currentState.stale !== false || @@ -375,22 +373,6 @@ export function useNavigationBuilder< }), [routeNamesKey, router] ); - const getState = useLatestCallback((): State => { - const currentState = getStateForKey(stateKeyRef.current); - if (currentState === undefined) { - return committedState; - } - if (currentState.stale !== false) { - throw new Error( - 'The mounted navigator no longer has complete state in the global navigation tree.' - ); - } - if (currentState.type !== undefined && currentState.type !== router.type) { - // The reset keeps the complete fields required by every navigator state. - return resetNavigatorState(currentState, router.type) as State; - } - return currentState as State; - }); const emitter = useEventEmitter>((e) => { const routeNames = []; @@ -463,12 +445,11 @@ export function useNavigationBuilder< const { keyedListeners, addKeyedListener } = useKeyedChildListeners(); const { isRoutePrevented, preventRemoveContextValue } = usePreventRemoveState({ - getState, - state, + state: committedState, }); useOnPreventRemove({ - getState, + state: committedState, isRoutePrevented, emitter, preventRemoveListeners: keyedListeners.preventRemove, @@ -525,9 +506,7 @@ export function useNavigationBuilder< if (isForeignType) { return; } - const committed = getState(); - - if (isArrayEqual(committed.routeNames, routeNames)) { + if (isArrayEqual(committedState.routeNames, routeNames)) { pendingRouteNamesRef.current = undefined; } else if (!isArrayEqual(pendingRouteNamesRef.current ?? [], routeNames)) { pendingRouteNamesRef.current = routeNames; @@ -537,9 +516,9 @@ export function useNavigationBuilder< action: { type: 'ROUTE_NAMES_CHANGED', payload: { routeNames }, - target: committed.key, + target: committedState.key, }, - originKey: committed.key, + originKey: committedState.key, }, }); } @@ -548,7 +527,7 @@ export function useNavigationBuilder< const navigation = useNavigationHelpers({ id: options.id, handleAction: onAction, - getState, + state: committedState, emitter, router, }); @@ -565,7 +544,7 @@ export function useNavigationBuilder< navigation, screenOptions, screenLayout, - getState, + state: committedState, addListener, addKeyedListener, router, diff --git a/packages/expo-router/src/react-navigation/core/useNavigationCache.tsx b/packages/expo-router/src/react-navigation/core/useNavigationCache.tsx index d60e45a0cba1fd..df9fc9f2be61b5 100644 --- a/packages/expo-router/src/react-navigation/core/useNavigationCache.tsx +++ b/packages/expo-router/src/react-navigation/core/useNavigationCache.tsx @@ -2,7 +2,6 @@ import * as React from 'react'; import { use } from 'react'; -import { isRoutePreloadedInStack } from '../../utils/stack'; import { CommonActions, type NavigationAction, @@ -21,7 +20,6 @@ type Options< > = { routes: State['routes']; routeNames: State['routeNames']; - getState: () => State; navigation: NavigationHelpers & Partial>; setOptions: ( @@ -47,6 +45,9 @@ type NavigationCache< * Hook to cache navigation objects for each screen in the navigator. * It's important to cache them to make sure navigation objects don't change between renders. * This lets us apply optimizations like `React.memo` to minimize re-rendering screens. + * Exception: a route's navigation object changes identity once when the route is promoted from + * preloaded to active. + * TODO(@ubax): consider resolving `isPreloaded` at call time to keep one object per route. */ export function useNavigationCache< State extends NavigationState, @@ -56,7 +57,6 @@ export function useNavigationCache< >({ routes, routeNames, - getState, navigation, setOptions, router, @@ -70,20 +70,19 @@ export function useNavigationCache< const cache = React.useMemo( () => ({ current: {} as NavigationCache }), // eslint-disable-next-line react-hooks/exhaustive-deps - [getState, navigation, setOptions, emitter] + [navigation, setOptions, emitter] ); // Keep name-keyed placeholders stable after their real route keys are created. - const validKeys = new Set([...routes.map((route) => route.key), ...routeNames]); + const routeKeys = [...routes.map((route) => route.key), ...routeNames]; + const validKeys = new Set(routeKeys.flatMap((key) => [key, `p\0${key}`])); cache.current = Object.fromEntries( Object.entries(cache.current).filter(([key]) => validKeys.has(key)) ); - const createNavigation = (route: { key: string; name: string }) => { + const createNavigation = (route: { key: string; name: string }, isPreloaded: boolean) => { const dispatchSync = (action: NavigationAction) => { - const state = getState(); - - if (isRoutePreloadedInStack(state, route)) { + if (isPreloaded) { if (process.env.NODE_ENV !== 'production') { console.warn( `Ignored a navigation action dispatched from the preloaded screen '${route.name}'. The screen is rendered for preloading and is not focused, so its actions would unexpectedly modify the visible stack. Wait until the screen is focused before dispatching.` @@ -97,8 +96,7 @@ export function useNavigationCache< }; const dispatch = (action: NavigationAction) => { - const state = getState(); - if (isRoutePreloadedInStack(state, route)) { + if (isPreloaded) { if (process.env.NODE_ENV !== 'production') { console.warn( `Ignored a navigation action dispatched from the preloaded screen '${route.name}'. The screen is rendered for preloading and is not focused, so its actions would unexpectedly modify the visible stack. Wait until the screen is focused before dispatching.` @@ -171,7 +169,7 @@ export function useNavigationCache< isFocused: () => { const state = rest.getState(); - if (state.routes[state.index]!.key !== route.key) { + if (state.routes[state.index]?.key !== route.key) { return false; } @@ -184,14 +182,15 @@ export function useNavigationCache< return navigationItem; }; - return (route: { key: string; name: string }) => { - const cachedNavigation = cache.current[route.key]; + return (route: { key: string; name: string }, isPreloaded: boolean) => { + const key = `${isPreloaded ? 'p\0' : ''}${route.key}`; + const cachedNavigation = cache.current[key]; if (cachedNavigation) { return cachedNavigation; } - const navigation = createNavigation(route); - cache.current[route.key] = navigation; + const navigation = createNavigation(route, isPreloaded); + cache.current[key] = navigation; return navigation; }; } diff --git a/packages/expo-router/src/react-navigation/core/useNavigationHelpers.tsx b/packages/expo-router/src/react-navigation/core/useNavigationHelpers.tsx index d7a93a6eac2ffd..587e6c99e2d233 100644 --- a/packages/expo-router/src/react-navigation/core/useNavigationHelpers.tsx +++ b/packages/expo-router/src/react-navigation/core/useNavigationHelpers.tsx @@ -3,6 +3,7 @@ import * as React from 'react'; import { use } from 'react'; import { useEnqueueRoutingIntent } from '../../global-state/routingQueueContext'; +import useLatestCallback from '../../utils/useLatestCallback'; import { CommonActions, type NavigationAction, @@ -21,7 +22,7 @@ PrivateValueStore; type Options = { id: string | undefined; handleAction: (action: NavigationAction) => void; - getState: () => State; + state: State; emitter: NavigationEventEmitter; router: Router; }; @@ -35,9 +36,11 @@ export function useNavigationHelpers< ActionHelpers extends Record void>, Action extends NavigationAction, EventMap extends Record, ->({ id: navigatorId, handleAction, getState, emitter, router }: Options) { +>({ id: navigatorId, handleAction, state, emitter, router }: Options) { const parentNavigationHelpers = use(NavigationContext); const enqueue = useEnqueueRoutingIntent(); + // Unlike handler-only Effect Events, the public accessor can be called during render. + const getState = useLatestCallback(() => state); return React.useMemo(() => { const dispatchSync = (action: Action) => { @@ -105,5 +108,5 @@ export function useNavigationHelpers< } as NavigationHelpers & ActionHelpers; return navigationHelpers; - }, [enqueue, router, parentNavigationHelpers, emitter.emit, getState, handleAction, navigatorId]); + }, [enqueue, router, parentNavigationHelpers, emitter.emit, handleAction, navigatorId]); } diff --git a/packages/expo-router/src/react-navigation/core/useOnPreventRemove.tsx b/packages/expo-router/src/react-navigation/core/useOnPreventRemove.tsx index 20281a12092081..5c2e537e2630ed 100644 --- a/packages/expo-router/src/react-navigation/core/useOnPreventRemove.tsx +++ b/packages/expo-router/src/react-navigation/core/useOnPreventRemove.tsx @@ -14,7 +14,7 @@ import type { NavigationEventEmitter } from './useEventEmitter'; import type { IsRoutePrevented } from './usePreventRemoveState'; type Options = { - getState: () => NavigationState; + state: NavigationState; isRoutePrevented: IsRoutePrevented; emitter: NavigationEventEmitter>; preventRemoveListeners: Record; @@ -120,7 +120,7 @@ export const emitBeforeRemove = ( }; export function useOnPreventRemove({ - getState, + state, isRoutePrevented, emitter, preventRemoveListeners, @@ -135,7 +135,6 @@ export function useOnPreventRemove({ } return addKeyedListener?.('preventRemove', routeKey, (action) => { - const state = getState(); return shouldPreventRemove( emitter, preventRemoveListeners, @@ -145,7 +144,7 @@ export function useOnPreventRemove({ action ); }); - }, [addKeyedListener, emitter, getState, isRoutePrevented, preventRemoveListeners, routeKey]); + }, [addKeyedListener, emitter, isRoutePrevented, preventRemoveListeners, routeKey, state]); React.useEffect(() => { if (!routeKey) { @@ -154,8 +153,7 @@ export function useOnPreventRemove({ // Forward beforeRemove into nested navigators when an ancestor removes their route. return addKeyedListener?.('beforeRemove', routeKey, (action) => { - const state = getState(); emitBeforeRemove(emitter, beforeRemoveListeners, getPreventableRoutes(state), [], action); }); - }, [addKeyedListener, beforeRemoveListeners, emitter, getState, routeKey]); + }, [addKeyedListener, beforeRemoveListeners, emitter, routeKey, state]); } diff --git a/packages/expo-router/src/react-navigation/core/useOptionsGetters.tsx b/packages/expo-router/src/react-navigation/core/useOptionsGetters.tsx index 87c8f3abca2d14..d85f51cab636aa 100644 --- a/packages/expo-router/src/react-navigation/core/useOptionsGetters.tsx +++ b/packages/expo-router/src/react-navigation/core/useOptionsGetters.tsx @@ -2,18 +2,17 @@ import * as React from 'react'; import { use } from 'react'; -import type { ParamListBase } from '../routers'; +import useLatestCallback from '../../utils/useLatestCallback'; import { NavigationBuilderContext } from './NavigationBuilderContext'; import { NavigationStateContext } from './NavigationStateContext'; -import type { NavigationProp } from './types'; +import { useIsRouteFocused } from './useIsFocused'; type Options = { key?: string; - navigation?: NavigationProp; options?: object | undefined; }; -export function useOptionsGetters({ key, options, navigation }: Options) { +export function useOptionsGetters({ key, options }: Options) { const optionsRef = React.useRef(options); const optionsGettersFromChildRef = React.useRef object | undefined | null>>( {} @@ -21,22 +20,20 @@ export function useOptionsGetters({ key, options, navigation }: Options) { const { onOptionsChange } = use(NavigationBuilderContext); const { addOptionsGetter: parentAddOptionsGetter } = use(NavigationStateContext); + const isFocused = useIsRouteFocused(key); const optionsChangeListener = React.useCallback(() => { - const isFocused = navigation?.isFocused() ?? true; const hasChildren = Object.keys(optionsGettersFromChildRef.current).length; if (isFocused && !hasChildren) { onOptionsChange(optionsRef.current ?? {}, key); } - }, [key, navigation, onOptionsChange]); + }, [isFocused, key, onOptionsChange]); React.useEffect(() => { optionsRef.current = options; optionsChangeListener(); - - return navigation?.addListener('focus', optionsChangeListener); - }, [navigation, options, optionsChangeListener]); + }, [options, optionsChangeListener]); const getOptionsFromListener = React.useCallback(() => { for (const key in optionsGettersFromChildRef.current) { @@ -53,9 +50,7 @@ export function useOptionsGetters({ key, options, navigation }: Options) { return null; }, []); - const getCurrentOptions = React.useCallback(() => { - const isFocused = navigation?.isFocused() ?? true; - + const getCurrentOptions = useLatestCallback(() => { if (!isFocused) { return null; } @@ -67,7 +62,7 @@ export function useOptionsGetters({ key, options, navigation }: Options) { } return optionsRef.current; - }, [navigation, getOptionsFromListener]); + }); React.useEffect(() => { return parentAddOptionsGetter?.(key!, getCurrentOptions); diff --git a/packages/expo-router/src/react-navigation/core/usePreventRemoveState.tsx b/packages/expo-router/src/react-navigation/core/usePreventRemoveState.tsx index ed2f14d0c3b93d..78b367e9f8d069 100644 --- a/packages/expo-router/src/react-navigation/core/usePreventRemoveState.tsx +++ b/packages/expo-router/src/react-navigation/core/usePreventRemoveState.tsx @@ -10,7 +10,6 @@ import { NavigationRouteContext } from './NavigationProvider'; import { type PreventedRoutes, PreventRemoveContext } from './PreventRemoveContext'; type Props = { - getState: () => NavigationState; state: NavigationState; }; @@ -33,7 +32,7 @@ const transformPreventedRoutes = (entries: PreventedRouteEntry[]): PreventedRout /** * Hook used for exposing removal prevention state to navigator views. */ -export function usePreventRemoveState({ getState, state }: Props) { +export function usePreventRemoveState({ state }: Props) { 'use no memo'; const [parentId] = React.useState(() => nanoid()); const entriesRef = React.useRef(new Map()); @@ -43,9 +42,9 @@ export function usePreventRemoveState({ getState, state }: Props) { const parentContext = use(PreventRemoveContext); const setParentPrevented = parentContext?.setPreventRemove; - const setPreventRemove = useLatestCallback( + const setPreventRemove = React.useCallback( (id: string, routeKey: string, preventRemove: boolean): void => { - if (preventRemove && getState().routes.every((route) => route.key !== routeKey)) { + if (preventRemove && state.routes.every((route) => route.key !== routeKey)) { throw new Error( `Couldn't find a route with the key ${routeKey}. Is your component inside NavigationContent?` ); @@ -70,13 +69,13 @@ export function usePreventRemoveState({ getState, state }: Props) { setEntries(next); if (route?.key !== undefined && setParentPrevented !== undefined) { - const state = getState(); const hasActiveEntry = [...next.values()].some( (entry) => entry.preventRemove && !isRoutePreloadedInStack(state, { key: entry.routeKey }) ); setParentPrevented(parentId, route.key, hasActiveEntry); } - } + }, + [parentId, route?.key, setParentPrevented, state] ); const activeEntries = React.useMemo( @@ -93,7 +92,7 @@ export function usePreventRemoveState({ getState, state }: Props) { (entry) => entry.routeKey === routeKey && entry.preventRemove && - !isRoutePreloadedInStack(getState(), { key: routeKey }) + !isRoutePreloadedInStack(state, { key: routeKey }) ) ); diff --git a/packages/expo-router/src/react-navigation/native/__tests__/useLinkBuilder.test.ios.tsx b/packages/expo-router/src/react-navigation/native/__tests__/useLinkBuilder.test.ios.tsx index 29eeacc39d7dd0..3915ed621354b6 100644 --- a/packages/expo-router/src/react-navigation/native/__tests__/useLinkBuilder.test.ios.tsx +++ b/packages/expo-router/src/react-navigation/native/__tests__/useLinkBuilder.test.ios.tsx @@ -1,6 +1,7 @@ import { render } from '@testing-library/react-native'; -import { NavigationRouteContext } from '../../core'; +import { NavigationHelpersContext, NavigationRouteContext } from '../../core'; +import type { NavigationHelpers, ParamListBase } from '../../core'; import { NavigationContainer } from '../../core/__tests__/__fixtures__/NavigationContainer'; import { createTestState, @@ -104,10 +105,10 @@ test('builds href in route context', () => { ); }); -test('builds href in stack navigator screen', () => { +test('builds href in stack navigator screen without reading navigation state imperatively', () => { expect.assertions(2); - const Test = () => { + const HrefProbe = () => { const { buildHref } = useLinkBuilder(); const href = buildHref('Foo'); @@ -117,6 +118,18 @@ test('builds href in stack navigator screen', () => { return null; }; + const Test = ({ navigation }: { navigation: NavigationHelpers }) => ( + + + + ); + const StackA = createStackNavigator<{ Foo: undefined }>(); render( diff --git a/packages/expo-router/src/react-navigation/native/__tests__/useScrollToTop.test.ios.tsx b/packages/expo-router/src/react-navigation/native/__tests__/useScrollToTop.test.ios.tsx new file mode 100644 index 00000000000000..af85e9e2fe31a0 --- /dev/null +++ b/packages/expo-router/src/react-navigation/native/__tests__/useScrollToTop.test.ios.tsx @@ -0,0 +1,138 @@ +import { userEvent } from '@testing-library/react-native'; +import { Pressable, View } from 'react-native'; + +import { router } from '../../../imperative-api'; +import { Stack } from '../../../layouts/Stack'; +import { Tabs } from '../../../layouts/Tabs'; +import { act, renderRouter, screen } from '../../../testing-library'; +import { useScrollToTop } from '../useScrollToTop'; + +function createScrollableScreen(scrollTo: jest.Mock) { + const ref = { current: { scrollTo } }; + + return function ScrollableScreen() { + useScrollToTop(ref); + return ; + }; +} + +function flushAnimationFrame() { + act(() => jest.runAllTimers()); +} + +test('scrolls a screen directly in the focused tab to the top', async () => { + const scrollTo = jest.fn(); + + renderRouter({ + _layout: () => ( + + + + + ), + index: createScrollableScreen(scrollTo), + second: () => , + }); + + await userEvent.press(screen.getByRole('button', { name: 'index, tab, 1 of 2' })); + flushAnimationFrame(); + + expect(scrollTo).toHaveBeenCalledWith({ y: 0, animated: true }); +}); + +test('does not scroll a screen in an unfocused tab', async () => { + const scrollTo = jest.fn(); + + renderRouter({ + _layout: () => ( + + + + + ), + index: () => , + second: createScrollableScreen(scrollTo), + }); + + await userEvent.press(screen.getByRole('button', { name: 'index, tab, 1 of 2' })); + flushAnimationFrame(); + + expect(scrollTo).not.toHaveBeenCalled(); +}); + +test('scrolls the first screen of a stack nested in a tab to the top', async () => { + const scrollTo = jest.fn(); + + renderRouter( + { + _layout: () => ( + + + + + ), + 'one/_layout': () => , + 'one/index': createScrollableScreen(scrollTo), + 'one/details': () => , + two: () => , + }, + { initialUrl: '/one' } + ); + + await userEvent.press(screen.getByRole('button', { name: 'one, tab, 1 of 2' })); + flushAnimationFrame(); + + expect(scrollTo).toHaveBeenCalledWith({ y: 0, animated: true }); +}); + +test('does not scroll a non-first screen of a stack nested in a tab', async () => { + const scrollTo = jest.fn(); + + renderRouter( + { + _layout: () => ( + + + + + ), + 'one/_layout': () => , + 'one/index': () => ( + router.push('/one/details')} + /> + ), + 'one/details': createScrollableScreen(scrollTo), + two: () => , + }, + { initialUrl: '/one' } + ); + await userEvent.press(screen.getByRole('button', { name: 'Details' })); + + await userEvent.press(screen.getByRole('button', { name: 'one, tab, 1 of 2' })); + flushAnimationFrame(); + + expect(scrollTo).not.toHaveBeenCalled(); +}); + +test('does not scroll when another tabPress listener prevents the default action', async () => { + const scrollTo = jest.fn(); + + renderRouter({ + _layout: () => ( + + event.preventDefault() }} /> + + + ), + index: createScrollableScreen(scrollTo), + second: () => , + }); + + await userEvent.press(screen.getByRole('button', { name: 'index, tab, 1 of 2' })); + flushAnimationFrame(); + + expect(scrollTo).not.toHaveBeenCalled(); +}); diff --git a/packages/expo-router/src/react-navigation/native/useLinkBuilder.tsx b/packages/expo-router/src/react-navigation/native/useLinkBuilder.tsx index b9857c8237573a..3f1603f4946d16 100644 --- a/packages/expo-router/src/react-navigation/native/useLinkBuilder.tsx +++ b/packages/expo-router/src/react-navigation/native/useLinkBuilder.tsx @@ -10,6 +10,7 @@ import { NavigationRouteContext, useStateForPath, } from '../core'; +import { NavigatorStateContext } from '../core/useNavigationState'; import type { NavigationState, PartialState } from '../routers'; import { LinkingContext } from './LinkingContext'; @@ -29,6 +30,7 @@ type ConfigItem = { export function useBuildHref() { const navigation = use(NavigationHelpersContext); const route = use(NavigationRouteContext); + const navigatorState = use(NavigatorStateContext); const { options } = use(LinkingContext); @@ -46,7 +48,7 @@ export function useBuildHref() { const isScreen = navigation && route?.key && focusedRouteState ? route.key === findFocusedRoute(focusedRouteState)?.key && - navigation.getState().routes.some((r) => r.key === route.key) + navigatorState?.routes.some((r) => r.key === route.key) : false; const stateForRoute: MinimalState = { @@ -87,7 +89,14 @@ export function useBuildHref() { return path; }, - [options?.config, route?.key, navigation, focusedRouteState, getPathFromStateHelper] + [ + options?.config, + route?.key, + navigation, + navigatorState, + focusedRouteState, + getPathFromStateHelper, + ] ); return buildHref; diff --git a/packages/expo-router/src/react-navigation/native/useScrollToTop.tsx b/packages/expo-router/src/react-navigation/native/useScrollToTop.tsx index e13ba6fa9b64ad..9dd79141dd6164 100644 --- a/packages/expo-router/src/react-navigation/native/useScrollToTop.tsx +++ b/packages/expo-router/src/react-navigation/native/useScrollToTop.tsx @@ -3,13 +3,8 @@ import * as React from 'react'; import { use } from 'react'; import type { ScrollView } from 'react-native'; -import { - type EventArg, - NavigationContext, - type NavigationProp, - type ParamListBase, - useRoute, -} from '../core'; +import { type EventArg, NavigationContext, useRoute } from '../core'; +import { NavigatorStateContext } from '../core/useNavigationState'; type ScrollOptions = { x?: number; y?: number; animated?: boolean }; @@ -55,7 +50,10 @@ function getScrollableNode(ref: React.RefObject) { export function useScrollToTop(ref: React.RefObject) { const navigation = use(NavigationContext); + const navigatorState = use(NavigatorStateContext); const route = useRoute(); + const isDirectlyInTabNavigator = navigatorState?.type === 'tab'; + const firstRouteKey = navigatorState?.routes[0]?.key; if (navigation === undefined) { throw new Error( @@ -64,63 +62,44 @@ export function useScrollToTop(ref: React.RefObject) { } React.useEffect(() => { - const tabNavigations: NavigationProp[] = []; - let currentNavigation = navigation; - // If the screen is nested inside multiple tab navigators, we should scroll to top for any of them - // So we need to find all the parent tab navigators and add the listeners there - while (currentNavigation) { - // TODO: Resolve every ancestor's navigator type at render time, as NavigatorTypeContext does - // for the nearest navigator. - if (currentNavigation.getState().type === 'tab') { - tabNavigations.push(currentNavigation); - } + const handler = (e: EventArg<'tabPress', true>) => { + // We should scroll to top only when the screen is focused + const isFocused = navigation.isFocused(); + + // In a nested stack navigator, tab press resets the stack to first screen + // So we should scroll to top only when we are on first screen + const isFirst = isDirectlyInTabNavigator || firstRouteKey === route.key; + + // Run the operation in the next frame so we're sure all listeners have been run + // This is necessary to know if preventDefault() has been called + requestAnimationFrame(() => { + const scrollable = getScrollableNode(ref) as ScrollableWrapper; + + if (isFocused && isFirst && scrollable && !e.defaultPrevented) { + if ('scrollToTop' in scrollable) { + scrollable.scrollToTop(); + } else if ('scrollTo' in scrollable) { + scrollable.scrollTo({ y: 0, animated: true }); + } else if ('scrollToOffset' in scrollable) { + scrollable.scrollToOffset({ offset: 0, animated: true }); + } else if ('scrollResponderScrollTo' in scrollable) { + scrollable.scrollResponderScrollTo({ y: 0, animated: true }); + } + } + }); + }; + const unsubscribers: (() => void)[] = []; + let currentNavigation: typeof navigation | undefined = navigation; + while (currentNavigation) { + // Non-tab navigators never emit `tabPress`, so these listeners are inert. + // @ts-expect-error: `tabPress` is emitted only by tab navigators. + unsubscribers.push(currentNavigation.addListener('tabPress', handler)); currentNavigation = currentNavigation.getParent(); } - if (tabNavigations.length === 0) { - return; - } - - const unsubscribers = tabNavigations.map((tab) => { - return tab.addListener( - // We don't wanna import tab types here to avoid extra deps - // in addition, there are multiple tab implementations - // @ts-expect-error the `tabPress` event is only available when navigation type is tab - 'tabPress', - (e: EventArg<'tabPress', true>) => { - // We should scroll to top only when the screen is focused - const isFocused = navigation.isFocused(); - - // In a nested stack navigator, tab press resets the stack to first screen - // So we should scroll to top only when we are on first screen - const isFirst = - tabNavigations.includes(navigation) || - navigation.getState().routes[0]!.key === route.key; - - // Run the operation in the next frame so we're sure all listeners have been run - // This is necessary to know if preventDefault() has been called - requestAnimationFrame(() => { - const scrollable = getScrollableNode(ref) as ScrollableWrapper; - - if (isFocused && isFirst && scrollable && !e.defaultPrevented) { - if ('scrollToTop' in scrollable) { - scrollable.scrollToTop(); - } else if ('scrollTo' in scrollable) { - scrollable.scrollTo({ y: 0, animated: true }); - } else if ('scrollToOffset' in scrollable) { - scrollable.scrollToOffset({ offset: 0, animated: true }); - } else if ('scrollResponderScrollTo' in scrollable) { - scrollable.scrollResponderScrollTo({ y: 0, animated: true }); - } - } - }); - } - ); - }); - return () => { unsubscribers.forEach((unsubscribe) => unsubscribe()); }; - }, [navigation, ref, route.key]); + }, [firstRouteKey, isDirectlyInTabNavigator, navigation, ref, route.key]); } diff --git a/packages/expo-router/src/ui/TabContext.tsx b/packages/expo-router/src/ui/TabContext.tsx index 60cdfadbe6364a..782d154e4dfcd2 100644 --- a/packages/expo-router/src/ui/TabContext.tsx +++ b/packages/expo-router/src/ui/TabContext.tsx @@ -96,6 +96,10 @@ export const TabContext = createContext({}); * @hidden */ export const TabTriggerMapContext = createContext({}); +/** + * @hidden + */ +export const TabNavigatorStatesContext = createContext>>({}); /** * @hidden */ diff --git a/packages/expo-router/src/ui/TabTrigger.tsx b/packages/expo-router/src/ui/TabTrigger.tsx index 8a89696e42304f..f7714a6dddf4d9 100644 --- a/packages/expo-router/src/ui/TabTrigger.tsx +++ b/packages/expo-router/src/ui/TabTrigger.tsx @@ -12,7 +12,7 @@ import { stripGroupSegmentsFromPath } from '../matchers'; import type { TabNavigationState } from '../react-navigation/native'; import type { Href } from '../types'; import { useNavigatorContext } from '../views/Navigator'; -import { TabTriggerMapContext } from './TabContext'; +import { TabNavigatorStatesContext, TabTriggerMapContext } from './TabContext'; import { buildTabAction, type TriggerMap } from './common'; type PressablePropsWithoutFunctionChildren = Omit & { @@ -149,6 +149,7 @@ export function useTabTrigger(options: TabTriggerProps): UseTabTriggerResult { const { state, navigation, contextKey, descriptors } = useNavigatorContext(); const { name, resetOnFocus, onPress, onLongPress } = options; const triggerMap = use(TabTriggerMapContext); + const navigatorStates = use(TabNavigatorStatesContext); const registry = use(RouterRegistryContext); const getTrigger = useCallback( @@ -172,9 +173,7 @@ export function useTabTrigger(options: TabTriggerProps): UseTabTriggerResult { // Parent triggers are inherited, so read the state of the navigator that registered them. const owningState = - config.type === 'internal' && config.contextKey !== contextKey - ? navigation?.getParent(config.contextKey)?.getState() - : state; + config.type === 'internal' ? navigatorStates[config.contextKey] : undefined; const routeIndex = config.type === 'internal' ? (owningState?.routes.findIndex((route) => route.name === config.routeNode.route) ?? -1) @@ -188,7 +187,7 @@ export function useTabTrigger(options: TabTriggerProps): UseTabTriggerResult { ...config, }; }, - [contextKey, descriptors, navigation, state, triggerMap] + [descriptors, navigatorStates, state, triggerMap] ); const trigger = name !== undefined ? getTrigger(name) : undefined; @@ -204,10 +203,7 @@ export function useTabTrigger(options: TabTriggerProps): UseTabTriggerResult { if (!registry) { throw new Error('Router registry is unavailable. This is likely a bug in expo-router.'); } - const owningState = - config.contextKey !== contextKey - ? navigation?.getParent(config.contextKey)?.getState() - : state; + const owningState = navigatorStates[config.contextKey]; if (!owningState) { return; } @@ -227,7 +223,7 @@ export function useTabTrigger(options: TabTriggerProps): UseTabTriggerResult { }); } }, - [contextKey, navigation, registry, state, triggerMap] + [contextKey, navigation, navigatorStates, registry, triggerMap] ); const handleOnPress = useCallback>( diff --git a/packages/expo-router/src/ui/Tabs.tsx b/packages/expo-router/src/ui/Tabs.tsx index 1fe3d376d30be3..b21d85460630f9 100644 --- a/packages/expo-router/src/ui/Tabs.tsx +++ b/packages/expo-router/src/ui/Tabs.tsx @@ -26,7 +26,7 @@ import { shouldLinkExternally } from '../utils/url'; import type { NavigatorContextValue } from '../views/Navigator'; import { NavigatorContext } from '../views/Navigator'; import type { ExpoTabsScreenOptions, TabNavigationEventMap, TabsContextValue } from './TabContext'; -import { TabTriggerMapContext } from './TabContext'; +import { TabNavigatorStatesContext, TabTriggerMapContext } from './TabContext'; import { isTabList } from './TabList'; import type { ExpoTabRouterOptions } from './TabRouter'; import { ExpoTabRouter } from './TabRouter'; @@ -151,6 +151,7 @@ export function useTabsWithTriggers(options: UseTabsWithTriggersOptions): TabsCo const { triggers, ...rest } = options; // Ensure we extend the parent triggers, so we can trigger them as well const parentTriggerMap = use(TabTriggerMapContext); + const parentNavigatorStates = use(TabNavigatorStatesContext); const routeNode = useRouteNode(); const contextKey = useContextKey(); const linking = use(LinkingContext).options; @@ -202,6 +203,10 @@ export function useTabsWithTriggers(options: UseTabsWithTriggersOptions): TabsCo ) as typeof sparseDescriptors, [describe, sparseDescriptors, state] ); + const navigatorStates = useMemo( + () => ({ ...parentNavigatorStates, [contextKey]: state }), + [contextKey, parentNavigatorStates, state] + ); const navigatorContextValue = useMemo( () => ({ @@ -218,9 +223,11 @@ export function useTabsWithTriggers(options: UseTabsWithTriggersOptions): TabsCo - - {children} - + + + {children} + + )) as TabsContextValue['NavigationContent']; From 42d012a4c487a6988d757224fe1f93da34fb87bf Mon Sep 17 00:00:00 2001 From: Jakub Tkacz <32908614+Ubax@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:44:27 +0200 Subject: [PATCH 6/8] [router] Remove devtools related stackRef (#49431) ## Why The `stackRef` is one of the blockers for concurrent react migration. It is used only in react-navigation/devtools which will no longer work with expo-router anyway. We will add our own devtools as part of https://linear.app/expo/issue/ENG-20826 ## How 1. Remove `stackRef` and `withStack` 2. Remove `stack` param from `__unsafe_action__` --------- Co-authored-by: Expo Bot <34669131+expo-bot@users.noreply.github.com> --- packages/expo-router/CHANGELOG.md | 1 + .../core/BaseNavigationContainer.tsx | 6 ++-- .../core/NavigationBuilderContext.tsx | 1 - .../src/react-navigation/core/types.tsx | 4 --- .../react-navigation/core/useDescriptors.tsx | 4 +-- .../core/useNavigationCache.tsx | 32 +++---------------- 6 files changed, 8 insertions(+), 40 deletions(-) diff --git a/packages/expo-router/CHANGELOG.md b/packages/expo-router/CHANGELOG.md index 90372c23c64965..25808e128c3576 100644 --- a/packages/expo-router/CHANGELOG.md +++ b/packages/expo-router/CHANGELOG.md @@ -80,6 +80,7 @@ ### ๐Ÿ’ก Others +- Remove the dev-only `stack` field from the `__unsafe_action__` event in `expo-router/react-navigation`. ([#49431](https://github.com/expo/expo/pull/49431) by [@Ubax](https://github.com/Ubax)) - Read navigation state from the React tree instead of imperative refs. ([#49433](https://github.com/expo/expo/pull/49433) by [@Ubax](https://github.com/Ubax)) - Scope routing queues to each router root and bind `useRouter()` to its owning container. ([#49351](https://github.com/expo/expo/pull/49351) by [@Ubax](https://github.com/Ubax)) - Derive `useIsFocused` from context. ([#49390](https://github.com/expo/expo/pull/49390) by [@Ubax](https://github.com/Ubax)) diff --git a/packages/expo-router/src/react-navigation/core/BaseNavigationContainer.tsx b/packages/expo-router/src/react-navigation/core/BaseNavigationContainer.tsx index c0c746c8e0c4c3..83e19ae8edcec2 100644 --- a/packages/expo-router/src/react-navigation/core/BaseNavigationContainer.tsx +++ b/packages/expo-router/src/react-navigation/core/BaseNavigationContainer.tsx @@ -102,14 +102,13 @@ function BaseNavigationContainerInner({ const registry = use(RouterRegistryContext)!; const emitter = useEventEmitter(); - // TODO(@ubax): investigate if this is really needed - const stackRef = React.useRef(undefined); // TODO(@ubax): invoke this callback from global reducer dispatches. // https://linear.app/expo/issue/ENG-26123 const onDispatchAction = useLatestCallback((action: NavigationAction, noop: boolean) => { + // TODO(@ubax): Capture dispatch stack traces in the expo-router devtools plugin. https://linear.app/expo/issue/ENG-20826 emitter.emit({ type: '__unsafe_action__', - data: { action, noop, stack: stackRef.current }, + data: { action, noop }, }); }); @@ -243,7 +242,6 @@ function BaseNavigationContainerInner({ resetNavigator, onDispatchAction, onOptionsChange, - stackRef, }), [addListener, addKeyedListener, handleAction, onDispatchAction, onOptionsChange, resetNavigator] ); diff --git a/packages/expo-router/src/react-navigation/core/NavigationBuilderContext.tsx b/packages/expo-router/src/react-navigation/core/NavigationBuilderContext.tsx index 8ee510922ffce7..266de82e37a01d 100644 --- a/packages/expo-router/src/react-navigation/core/NavigationBuilderContext.tsx +++ b/packages/expo-router/src/react-navigation/core/NavigationBuilderContext.tsx @@ -42,7 +42,6 @@ export const NavigationBuilderContext = React.createContext<{ addKeyedListener?: AddKeyedListener; onDispatchAction: (action: NavigationAction, noop: boolean) => void; onOptionsChange: (options: object, routeKey?: string) => void; - stackRef?: React.MutableRefObject; }>({ handleAction: () => undefined, resetNavigator: () => undefined, diff --git a/packages/expo-router/src/react-navigation/core/types.tsx b/packages/expo-router/src/react-navigation/core/types.tsx index 941026e50213aa..f3696e6d206334 100644 --- a/packages/expo-router/src/react-navigation/core/types.tsx +++ b/packages/expo-router/src/react-navigation/core/types.tsx @@ -754,10 +754,6 @@ export type NavigationContainerEventMap = { * Whether the action was a no-op, i.e. resulted in any state changes. */ noop: boolean; - /** - * Stack trace of the action, this will only be available during development. - */ - stack: string | undefined; }; }; }; diff --git a/packages/expo-router/src/react-navigation/core/useDescriptors.tsx b/packages/expo-router/src/react-navigation/core/useDescriptors.tsx index 06d5590b2ef387..bacefc2adee3d5 100644 --- a/packages/expo-router/src/react-navigation/core/useDescriptors.tsx +++ b/packages/expo-router/src/react-navigation/core/useDescriptors.tsx @@ -108,7 +108,7 @@ export function useDescriptors< }: Options) { const theme = use(ThemeContext); const [options, setOptions] = React.useState>({}); - const { handleAction, resetNavigator, onDispatchAction, onOptionsChange, stackRef } = + const { handleAction, resetNavigator, onDispatchAction, onOptionsChange } = use(NavigationBuilderContext); const context = React.useMemo( @@ -120,7 +120,6 @@ export function useDescriptors< addKeyedListener, onDispatchAction, onOptionsChange, - stackRef, }), [ navigation, @@ -130,7 +129,6 @@ export function useDescriptors< addKeyedListener, onDispatchAction, onOptionsChange, - stackRef, ] ); diff --git a/packages/expo-router/src/react-navigation/core/useNavigationCache.tsx b/packages/expo-router/src/react-navigation/core/useNavigationCache.tsx index df9fc9f2be61b5..2d875f556570ca 100644 --- a/packages/expo-router/src/react-navigation/core/useNavigationCache.tsx +++ b/packages/expo-router/src/react-navigation/core/useNavigationCache.tsx @@ -1,6 +1,5 @@ 'use client'; import * as React from 'react'; -import { use } from 'react'; import { CommonActions, @@ -9,7 +8,6 @@ import { type ParamListBase, type Router, } from '../routers'; -import { NavigationBuilderContext } from './NavigationBuilderContext'; import type { NavigationHelpers, NavigationProp } from './types'; import type { NavigationEventEmitter } from './useEventEmitter'; @@ -62,8 +60,6 @@ export function useNavigationCache< router, emitter, }: Options) { - const { stackRef } = use(NavigationBuilderContext); - // Cache object which holds navigation objects for each screen // We use `React.useMemo` instead of `React.useRef` coz we want to invalidate it when deps change // In reality, these deps will rarely change, if ever @@ -108,24 +104,6 @@ export function useNavigationCache< navigation.dispatch({ source: route.key, ...action }); }; - const withStack = (callback: () => void) => { - let isStackSet = false; - - try { - if (process.env.NODE_ENV !== 'production' && stackRef && !stackRef.current) { - // Capture the stack trace for devtools - stackRef.current = new Error().stack; - isStackSet = true; - } - - callback(); - } finally { - if (isStackSet && stackRef) { - stackRef.current = undefined; - } - } - }; - const actions = { ...router.actionCreators, ...CommonActions, @@ -133,10 +111,8 @@ export function useNavigationCache< const helpers = Object.keys(actions).reduce void>>((acc, name) => { acc[name] = (...args: any) => - withStack(() => - // @ts-expect-error: name is a valid key, but TypeScript is dumb - dispatch(actions[name](...args)) - ); + // @ts-expect-error: name is a valid key, but TypeScript is dumb + dispatch(actions[name](...args)); return acc; }, {}); @@ -149,8 +125,8 @@ export function useNavigationCache< ...helpers, // FIXME: too much work to fix the types for now ...(emitter.create(route.key) as any), - dispatch: (action: NavigationAction) => withStack(() => dispatch(action)), - dispatchSync: (action: NavigationAction) => withStack(() => dispatchSync(action)), + dispatch, + dispatchSync, getParent: (id?: string) => { if (id !== undefined && id === rest.getId()) { // If the passed id is the same as the current navigation id, From b418a8a3a8028081a26321041c5716b6e317516a Mon Sep 17 00:00:00 2001 From: Jakub Tkacz <32908614+Ubax@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:44:27 +0200 Subject: [PATCH 7/8] [router] remove global store (#49403) # Why Remove the global imperative `store` # How 1. Replace usages of `store.linking` and `store.routeNode` with in-tree RouterConfig context 2. Remove `store` 3. Fix tests 4. Replace `store.state` with `navigationRef.getRootState()` # Test Plan CI # Checklist - [ ] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [ ] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). - [ ] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) --- packages/expo-router/AGENTS.md | 5 +- packages/expo-router/CHANGELOG.md | 1 + packages/expo-router/src/ExpoRoot.tsx | 32 +++-- .../src/__tests__/hashs.test.ios.tsx | 8 +- .../src/__tests__/headless-tabs.test.ios.tsx | 6 +- .../__tests__/hmr-route-changes.test.ios.tsx | 12 +- .../__tests__/initialRouteName.test.ios.tsx | 10 +- .../src/__tests__/navigation.test.ios.tsx | 29 ++--- .../src/__tests__/prefetch.test.ios.tsx | 26 ++-- .../src/__tests__/protected.test.ios.tsx | 26 +++- .../src/__tests__/push.test.ios.tsx | 14 +-- .../src/__tests__/redirects.test.ios.tsx | 22 ++-- .../__tests__/routerActionState.test.ios.tsx | 10 +- .../src/__tests__/search-params.test.ios.tsx | 14 +-- .../src/__tests__/stacks.test.ios.tsx | 10 +- .../src/__tests__/tabs.test.ios.tsx | 4 +- .../src/fork/NavigationContainer.tsx | 9 +- .../src/fork/__tests__/__fixtures__/store.tsx | 41 +++---- .../fork/__tests__/useLinking.test.ios.tsx | 79 +++++++++--- .../fork/__tests__/useLinking.test.web.tsx | 42 ++++--- .../expo-router/src/fork/useLinking.native.ts | 38 +++--- packages/expo-router/src/fork/useLinking.ts | 46 ++++--- packages/expo-router/src/getLinkingConfig.ts | 2 +- .../expo-router/src/getRoutesRedirects.tsx | 2 +- .../global-state/__tests__/router.test.ios.ts | 77 ++++++------ .../__tests__/routerRegistry.test.ios.tsx | 10 +- .../global-state/__tests__/store.test.ios.ts | 87 -------------- .../useNavigationTreeReducer.test.ios.tsx | 63 ++++++++-- .../src/global-state/navigationRef.ts | 4 + .../src/global-state/router-store.tsx | 8 -- .../expo-router/src/global-state/router.ts | 19 ++- .../src/global-state/routerConfigContext.ts | 15 +++ .../src/global-state/sort-routes.ts | 10 -- .../expo-router/src/global-state/store.ts | 99 ---------------- .../src/global-state/storeContext.ts | 19 --- .../global-state/useNavigationTreeReducer.ts | 22 +++- .../expo-router/src/global-state/useStore.ts | 84 ++----------- .../__tests__/useRootNavigation.test.ios.tsx | 30 +++++ .../src/hooks/useNavigationContainerRef.ts | 5 +- .../src/hooks/useRootNavigation.ts | 6 +- .../__tests__/StackClient.test.web.tsx | 7 +- packages/expo-router/src/link/linking.ts | 2 +- .../src/link/preview/HrefPreview.tsx | 4 +- .../link/preview/__tests__/utils.test.ios.tsx | 112 +++++++++--------- .../src/link/preview/useNextScreenId.ts | 18 +-- .../expo-router/src/link/preview/utils.ts | 2 +- .../src/link/useLoadedNavigation.ts | 20 ++-- .../src/navigationEvents/navigation.ts | 21 ---- .../core/BaseNavigationContainer.tsx | 11 +- .../__tests__/removePrevented.test.web.tsx | 13 +- .../expo-router/src/testing-library/index.tsx | 17 ++- packages/expo-router/src/utils/splash.ts | 18 +++ packages/expo-router/src/views/useSitemap.tsx | 4 +- 53 files changed, 597 insertions(+), 698 deletions(-) delete mode 100644 packages/expo-router/src/global-state/__tests__/store.test.ios.ts create mode 100644 packages/expo-router/src/global-state/navigationRef.ts delete mode 100644 packages/expo-router/src/global-state/router-store.tsx create mode 100644 packages/expo-router/src/global-state/routerConfigContext.ts delete mode 100644 packages/expo-router/src/global-state/sort-routes.ts delete mode 100644 packages/expo-router/src/global-state/store.ts delete mode 100644 packages/expo-router/src/global-state/storeContext.ts create mode 100644 packages/expo-router/src/hooks/__tests__/useRootNavigation.test.ios.tsx delete mode 100644 packages/expo-router/src/navigationEvents/navigation.ts diff --git a/packages/expo-router/AGENTS.md b/packages/expo-router/AGENTS.md index a279fec0381a58..68207124d44fe8 100644 --- a/packages/expo-router/AGENTS.md +++ b/packages/expo-router/AGENTS.md @@ -21,7 +21,8 @@ File-based routing library for React Native and web applications. It provides au โ”‚ โ”œโ”€โ”€ matchers.tsx # Route segment pattern matching โ”‚ โ”‚ โ”‚ โ”œโ”€โ”€ global-state/ # State management -โ”‚ โ”‚ โ”œโ”€โ”€ router-store.tsx # Zustand store for router state +โ”‚ โ”‚ โ”œโ”€โ”€ routerConfigContext.ts # Static router configuration context +โ”‚ โ”‚ โ”œโ”€โ”€ navigationRef.ts # Imperative navigation ref โ”‚ โ”‚ โ”œโ”€โ”€ routing.ts # Navigation queue and routing functions โ”‚ โ”‚ โ”œโ”€โ”€ getRouteInfoFromState.ts, routeInfoCache.ts, useRouteInfo.ts # Current route information โ”‚ โ”‚ โ””โ”€โ”€ serverLocationContext.ts # Server-side location context @@ -259,7 +260,7 @@ const screenProps = MockedComponent.mock.calls[1][0]; ### State Management -- **RouterStore** (`global-state/router-store.tsx`): The global store managing navigation state, and making it accessible imperatively via the `store` object +- **Router state**: Use `RouterConfigContext`, `NavigationContainerRefContext`, and `RootNavigationStateContext` for in-tree reads, and `navigationRef` for the imperative `router.*` API - **Routing Queue** (`global-state/routing.ts`): Batches navigation actions and processes them sequentially ### Platform-Specific Code diff --git a/packages/expo-router/CHANGELOG.md b/packages/expo-router/CHANGELOG.md index 25808e128c3576..50bb3808cd2206 100644 --- a/packages/expo-router/CHANGELOG.md +++ b/packages/expo-router/CHANGELOG.md @@ -80,6 +80,7 @@ ### ๐Ÿ’ก Others +- Remove module-level mutable navigation state from Expo Router. ([#49403](https://github.com/expo/expo/pull/49403) by [@Ubax](https://github.com/Ubax)) - Remove the dev-only `stack` field from the `__unsafe_action__` event in `expo-router/react-navigation`. ([#49431](https://github.com/expo/expo/pull/49431) by [@Ubax](https://github.com/Ubax)) - Read navigation state from the React tree instead of imperative refs. ([#49433](https://github.com/expo/expo/pull/49433) by [@Ubax](https://github.com/Ubax)) - Scope routing queues to each router root and bind `useRouter()` to its owning container. ([#49351](https://github.com/expo/expo/pull/49351) by [@Ubax](https://github.com/Ubax)) diff --git a/packages/expo-router/src/ExpoRoot.tsx b/packages/expo-router/src/ExpoRoot.tsx index 2be4c4fc2086d3..bee677176a94fa 100644 --- a/packages/expo-router/src/ExpoRoot.tsx +++ b/packages/expo-router/src/ExpoRoot.tsx @@ -8,19 +8,21 @@ import { INTERNAL_SLOT_NAME, NOT_FOUND_ROUTE_NAME, SITEMAP_ROUTE_NAME } from './ import { useDomComponentNavigation } from './domComponents/useDomComponentNavigation'; import { NavigationContainer as UpstreamNavigationContainer } from './fork/NavigationContainer'; import type { ExpoLinkingOptions } from './getLinkingConfig'; -import { useStore } from './global-state/router-store'; +import { navigationRef } from './global-state/navigationRef'; +import { RouterConfigContext } from './global-state/routerConfigContext'; import { RouterRegistryProvider } from './global-state/routerRegistry'; import { RoutingQueueProvider } from './global-state/routingQueueContext'; -import { maybeHideSplashScreen } from './global-state/store'; -import { StoreContext } from './global-state/storeContext'; +import { useRouterConfig } from './global-state/useStore'; import { shouldAppendNotFound, shouldAppendSitemap } from './global-state/utils'; import { LinkPreviewContextProvider } from './link/preview/LinkPreviewContext'; -import { handleNavigationOnReady } from './navigationEvents/navigation'; +import { emit } from './navigationEvents'; import { Screen } from './primitives'; +import { useClientLayoutEffect } from './react-navigation/core/useClientLayoutEffect'; import type { LinkingOptions } from './react-navigation/native'; import { StackRouter, useNavigationBuilder } from './react-navigation/native'; import { initScreensFeatureFlags } from './screensFeatureFlags'; import type { RequireContext } from './types'; +import { maybeHideSplashScreen } from './utils/splash'; import { parseUrlUsingCustomBase } from './utils/url'; import { RootUnmatched } from './views/RootUnmatched'; import { Sitemap } from './views/Sitemap'; @@ -95,7 +97,6 @@ const initialUrl = : undefined; function onNavigationReady() { - handleNavigationOnReady(); maybeHideSplashScreen(); } @@ -122,8 +123,21 @@ function ContextNavigator({ return undefined; }, []); - const storeValue = useStore(context, linking, serverUrl); - const { navigationRef, rootComponent, linking: linkingConfig, routeNode } = storeValue; + const { routerConfig, rootComponent } = useRouterConfig(context, linking, serverUrl); + const { linking: linkingConfig, routeNode } = routerConfig; + + useClientLayoutEffect(() => { + return navigationRef.addListener('__unsafe_action__', (event) => { + const state = navigationRef.getRootState(); + if (!event.data.noop && state) { + emit('actionDispatched', { + actionType: event.data.action.type, + payload: event.data.action.payload, + state, + }); + } + }); + }, []); useDomComponentNavigation(); @@ -144,7 +158,7 @@ function ContextNavigator({ } return ( - + - + ); } diff --git a/packages/expo-router/src/__tests__/hashs.test.ios.tsx b/packages/expo-router/src/__tests__/hashs.test.ios.tsx index 789e2075684239..18f54bdf4dd8d3 100644 --- a/packages/expo-router/src/__tests__/hashs.test.ios.tsx +++ b/packages/expo-router/src/__tests__/hashs.test.ios.tsx @@ -2,7 +2,7 @@ import { act, screen } from '@testing-library/react-native'; import { Text } from 'react-native'; import { router } from '../exports'; -import { store } from '../global-state/router-store'; +import { navigationRef } from '../global-state/navigationRef'; import { renderRouter } from '../testing-library'; import { parseUrlUsingCustomBase } from '../utils/url'; import { expectCompleteStateToMatch } from './assertCompleteState'; @@ -23,7 +23,7 @@ it('can push a hash url', () => { act(() => router.push('/test#b')); act(() => router.push('/test#c')); - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -208,7 +208,7 @@ it('navigating to the same route with a hash will only rerender the screen', () index: () => , }); - expectCompleteStateToMatch(store.state, { + expectCompleteStateToMatch(navigationRef.getRootState(), { index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -238,7 +238,7 @@ it('navigating to the same route with a hash will only rerender the screen', () act(() => router.navigate('/?#hash1')); - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], diff --git a/packages/expo-router/src/__tests__/headless-tabs.test.ios.tsx b/packages/expo-router/src/__tests__/headless-tabs.test.ios.tsx index 25017c3a7ab7d7..cb3e5aec44384a 100644 --- a/packages/expo-router/src/__tests__/headless-tabs.test.ios.tsx +++ b/packages/expo-router/src/__tests__/headless-tabs.test.ios.tsx @@ -4,7 +4,7 @@ import React, { forwardRef, useEffect, useState } from 'react'; import type { ViewProps } from 'react-native'; import { View, Text, Button } from 'react-native'; -import { store } from '../global-state/router-store'; +import { navigationRef } from '../global-state/navigationRef'; import { useLocalSearchParams } from '../hooks'; import { router } from '../imperative-api'; import { useGuardRedirect } from '../layouts/GuardContext'; @@ -1439,7 +1439,7 @@ it.skip('dispatches only one action when re-tapping active tab with nested stack expect(screen.getByTestId('movies-nested-details')).toBeVisible(); // Set up listener to track dispatched actions before re-tapping - const unsubscribe = store.navigationRef.current!.addListener('__unsafe_action__', (e) => { + const unsubscribe = navigationRef.current!.addListener('__unsafe_action__', (e) => { dispatchedActions.push(e.data.action); }); @@ -1494,7 +1494,7 @@ it.skip('JSTabs dispatches only one action when re-tapping active tab with neste expect(screen.getByTestId('movies-nested-details')).toBeVisible(); // Set up listener to track dispatched actions before re-tapping - const unsubscribe = store.navigationRef.current!.addListener('__unsafe_action__', (e) => { + const unsubscribe = navigationRef.current!.addListener('__unsafe_action__', (e) => { dispatchedActions.push(e.data.action); }); diff --git a/packages/expo-router/src/__tests__/hmr-route-changes.test.ios.tsx b/packages/expo-router/src/__tests__/hmr-route-changes.test.ios.tsx index 15b9b99700f0cf..c20d9999486387 100644 --- a/packages/expo-router/src/__tests__/hmr-route-changes.test.ios.tsx +++ b/packages/expo-router/src/__tests__/hmr-route-changes.test.ios.tsx @@ -4,7 +4,7 @@ import { useCallback, type ReactElement } from 'react'; import { Text } from 'react-native'; import { ExpoRoot } from '../ExpoRoot'; -import { store } from '../global-state/router-store'; +import { navigationRef } from '../global-state/navigationRef'; import { router } from '../imperative-api'; import Stack from '../layouts/Stack'; import Tabs from '../layouts/Tabs'; @@ -28,11 +28,11 @@ it('preserves live navigation state when the initial location changes on re-rend }; const result = renderRouter(routes, { initialUrl: '/' }); act(() => router.push('/second')); - const navigationState = store.state; + const navigationState = navigationRef.getRootState(); result.rerender(); - expect(store.state).toStrictEqual(navigationState); + expect(navigationRef.getRootState()).toStrictEqual(navigationState); expect(screen.getByTestId('second')).toBeVisible(); }); @@ -161,7 +161,7 @@ it('does not crash when a route file is renamed after navigation', () => { ).not.toThrow(); expect(screen.getByTestId('index')).toBeVisible(); - expect(store.navigationRef.current?.getRootState().routes[0]!.state!.routeNames).toStrictEqual([ + expect(navigationRef.current?.getRootState().routes[0]!.state!.routeNames).toStrictEqual([ 'index', 'third', ]); @@ -274,7 +274,7 @@ it('preserves surviving stack history when a route file is renamed', () => { expect(screen.getByTestId('details')).toBeVisible(); expect( - store.navigationRef.current?.getRootState().routes[0]!.state!.routes.map((route) => route.name) + navigationRef.current?.getRootState().routes[0]!.state!.routes.map((route) => route.name) ).toStrictEqual(['index', 'details']); act(() => router.back()); @@ -306,7 +306,7 @@ it('does not crash when a tab route file is renamed after navigation', () => { ).not.toThrow(); expect(screen.getByTestId('index')).toBeVisible(); - expect(store.navigationRef.current?.getRootState().routes[0]!.state!.routeNames).toStrictEqual([ + expect(navigationRef.current?.getRootState().routes[0]!.state!.routeNames).toStrictEqual([ 'index', 'third', ]); diff --git a/packages/expo-router/src/__tests__/initialRouteName.test.ios.tsx b/packages/expo-router/src/__tests__/initialRouteName.test.ios.tsx index 7c5689832b7946..2e760fa884fae2 100644 --- a/packages/expo-router/src/__tests__/initialRouteName.test.ios.tsx +++ b/packages/expo-router/src/__tests__/initialRouteName.test.ios.tsx @@ -1,7 +1,7 @@ import { screen, act } from '@testing-library/react-native'; import { Text } from 'react-native'; -import { store } from '../global-state/router-store'; +import { navigationRef } from '../global-state/navigationRef'; import { useLocalSearchParams } from '../hooks'; import { router } from '../imperative-api'; import Stack from '../layouts/Stack'; @@ -101,7 +101,7 @@ it('push should include (group)/index as an anchor route when using withAnchor', }); // Initial complete state - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -131,7 +131,7 @@ it('push should include (group)/index as an anchor route when using withAnchor', act(() => router.push('/orange', { withAnchor: true })); - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -198,7 +198,7 @@ it('push should ignore (group)/index as an initial route if no anchor is specifi }); // Initial complete state - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -228,7 +228,7 @@ it('push should ignore (group)/index as an initial route if no anchor is specifi act(() => router.push('/orange')); - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], diff --git a/packages/expo-router/src/__tests__/navigation.test.ios.tsx b/packages/expo-router/src/__tests__/navigation.test.ios.tsx index bfc0e290eeeffa..55d218e2653751 100644 --- a/packages/expo-router/src/__tests__/navigation.test.ios.tsx +++ b/packages/expo-router/src/__tests__/navigation.test.ios.tsx @@ -11,7 +11,7 @@ import { Slot, usePathname, } from '../exports'; -import { store } from '../global-state/router-store'; +import { navigationRef } from '../global-state/navigationRef'; import { Stack } from '../layouts/Stack'; import { Tabs } from '../layouts/Tabs'; import { Link, Redirect } from '../link'; @@ -1842,20 +1842,21 @@ it('multiple pushes to different stack are executed in order and added separatel expect(screen.queryByTestId('d')).toBeNull(); expect(screen).toHavePathname('/b/e'); - expect(store.state!.index).toBe(0); - expect(store.state!.routes).toHaveLength(1); - expect(store.state!.routes[0]!.name).toBe('__root'); + const rootState = navigationRef.getRootState(); + expect(rootState.index).toBe(0); + expect(rootState.routes).toHaveLength(1); + expect(rootState.routes[0]!.name).toBe('__root'); // Both pushes from 'c' will create new routes in root layout. This is because both pushes are happening on the same state, where there is no 'b' stack yet. - expect(store.state!.routes[0]!.state!.routes).toHaveLength(3); - expect(store.state!.routes[0]!.state!.routes[0]!.name).toBe('a'); - expect(store.state!.routes[0]!.state!.routes[0]!.state!.routes).toHaveLength(1); - expect(store.state!.routes[0]!.state!.routes[0]!.state!.routes[0]!.name).toBe('c'); - expect(store.state!.routes[0]!.state!.routes[1]!.name).toBe('b'); - expect(store.state!.routes[0]!.state!.routes[1]!.state!.routes).toHaveLength(1); - expect(store.state!.routes[0]!.state!.routes[1]!.state!.routes[0]!.name).toBe('d'); - expect(store.state!.routes[0]!.state!.routes[2]!.name).toBe('b'); - expect(store.state!.routes[0]!.state!.routes[2]!.state!.routes).toHaveLength(1); - expect(store.state!.routes[0]!.state!.routes[2]!.state!.routes[0]!.name).toBe('e'); + expect(rootState.routes[0]!.state!.routes).toHaveLength(3); + expect(rootState.routes[0]!.state!.routes[0]!.name).toBe('a'); + expect(rootState.routes[0]!.state!.routes[0]!.state!.routes).toHaveLength(1); + expect(rootState.routes[0]!.state!.routes[0]!.state!.routes[0]!.name).toBe('c'); + expect(rootState.routes[0]!.state!.routes[1]!.name).toBe('b'); + expect(rootState.routes[0]!.state!.routes[1]!.state!.routes).toHaveLength(1); + expect(rootState.routes[0]!.state!.routes[1]!.state!.routes[0]!.name).toBe('d'); + expect(rootState.routes[0]!.state!.routes[2]!.name).toBe('b'); + expect(rootState.routes[0]!.state!.routes[2]!.state!.routes).toHaveLength(1); + expect(rootState.routes[0]!.state!.routes[2]!.state!.routes[0]!.name).toBe('e'); act(() => router.back()); expect(screen.getByTestId('d')).toBeVisible(); diff --git a/packages/expo-router/src/__tests__/prefetch.test.ios.tsx b/packages/expo-router/src/__tests__/prefetch.test.ios.tsx index c70a4f0eaefaad..cf9683617433e5 100644 --- a/packages/expo-router/src/__tests__/prefetch.test.ios.tsx +++ b/packages/expo-router/src/__tests__/prefetch.test.ios.tsx @@ -2,7 +2,7 @@ import { screen, act } from '@testing-library/react-native'; import { useEffect } from 'react'; import { Text } from 'react-native'; -import { store } from '../global-state/router-store'; +import { navigationRef } from '../global-state/navigationRef'; import { router } from '../imperative-api'; import { Stack } from '../layouts/Stack'; import Tabs from '../layouts/Tabs'; @@ -36,7 +36,7 @@ it('prefetch a sibling route', () => { }, }); - expectCompleteStateToMatch(store.state, { + expectCompleteStateToMatch(navigationRef.getRootState(), { index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -68,7 +68,7 @@ it('prefetch a sibling route', () => { router.prefetch('/test'); }); - expectCompleteStateToMatch(store.state, { + expectCompleteStateToMatch(navigationRef.getRootState(), { index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -137,7 +137,7 @@ it('will prefetch the correct route within a group', () => { '(b)/test': () => null, }); - expectCompleteStateToMatch(store.state, { + expectCompleteStateToMatch(navigationRef.getRootState(), { index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -169,7 +169,7 @@ it('will prefetch the correct route within a group', () => { router.prefetch('/test'); }); - expectCompleteStateToMatch(store.state, { + expectCompleteStateToMatch(navigationRef.getRootState(), { index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -212,7 +212,7 @@ it('will prefetch the correct route within nested groups', () => { '(b)/test': () => null, }); - expectCompleteStateToMatch(store.state, { + expectCompleteStateToMatch(navigationRef.getRootState(), { index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -244,7 +244,7 @@ it('will prefetch the correct route within nested groups', () => { router.prefetch('/test'); }); - expectCompleteStateToMatch(store.state, { + expectCompleteStateToMatch(navigationRef.getRootState(), { index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -285,7 +285,7 @@ it('works with relative Href', () => { test: () => null, }); - expectCompleteStateToMatch(store.state, { + expectCompleteStateToMatch(navigationRef.getRootState(), { index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -317,7 +317,7 @@ it('works with relative Href', () => { router.prefetch('./test'); }); - expectCompleteStateToMatch(store.state, { + expectCompleteStateToMatch(navigationRef.getRootState(), { index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -358,7 +358,7 @@ it('works with params', () => { test: () => null, }); - expectCompleteStateToMatch(store.state, { + expectCompleteStateToMatch(navigationRef.getRootState(), { index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -440,7 +440,7 @@ it('ignores the current route', () => { } ); - expectCompleteStateToMatch(store.state, { + expectCompleteStateToMatch(navigationRef.getRootState(), { index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -560,7 +560,7 @@ it('can prefetch a deeply nested route', () => { } ); - expectCompleteStateToMatch(store.state, { + expectCompleteStateToMatch(navigationRef.getRootState(), { index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -707,7 +707,7 @@ it('can prefetch a parent route', () => { } ); - expectCompleteStateToMatch(store.state, { + expectCompleteStateToMatch(navigationRef.getRootState(), { index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], diff --git a/packages/expo-router/src/__tests__/protected.test.ios.tsx b/packages/expo-router/src/__tests__/protected.test.ios.tsx index 4114b6f661fefe..f96642a361c423 100644 --- a/packages/expo-router/src/__tests__/protected.test.ios.tsx +++ b/packages/expo-router/src/__tests__/protected.test.ios.tsx @@ -3,7 +3,7 @@ import type { Dispatch, SetStateAction } from 'react'; import { createContext, use, useState } from 'react'; import { Text } from 'react-native'; -import { store } from '../global-state/router-store'; +import { navigationRef } from '../global-state/navigationRef'; import { router } from '../imperative-api'; import Stack from '../layouts/Stack'; import Tabs from '../layouts/Tabs'; @@ -49,7 +49,12 @@ it('redirects a guarded route to the anchor default during the initial load', () expect(screen.getByTestId('a')).toBeVisible(); expect(screen).toHavePathname('/a'); - expect(store.state!.routes[0]!.state!.routeNames).toStrictEqual(['a', 'index', 'b', 'c']); + expect(navigationRef.getRootState().routes[0]!.state!.routeNames).toStrictEqual([ + 'a', + 'index', + 'b', + 'c', + ]); }); it('redirects nested guarded routes to the anchor and unlocks them as guards flip', () => { @@ -138,7 +143,12 @@ it('redirects nested guarded routes to the anchor and unlocks them as guards fli expect(screen.getByTestId('c')).toBeVisible(); expect(screen).toHavePathname('/c'); - expect(store.state!.routes[0]!.state!.routeNames).toStrictEqual(['a', 'b', 'c', 'index']); + expect(navigationRef.getRootState().routes[0]!.state!.routeNames).toStrictEqual([ + 'a', + 'b', + 'c', + 'index', + ]); }); it('defaults a guarded route to the navigator anchor', () => { @@ -187,7 +197,11 @@ it('defaults a guarded route to the navigator anchor', () => { expect(screen.getByTestId('a')).toBeVisible(); expect(screen).toHavePathname('/a'); - expect(store.state!.routes[0]!.state!.routeNames).toStrictEqual(['a', 'b', 'index']); + expect(navigationRef.getRootState().routes[0]!.state!.routeNames).toStrictEqual([ + 'a', + 'b', + 'index', + ]); }); it('redirects a guarded route to an explicit redirectTo target', () => { @@ -641,7 +655,7 @@ describe('all routes guarded', () => { expect(screen.getByTestId('second')).toBeVisible(); expect(screen).toHavePathname('/second'); - const stateBefore = store.state!.routes[0]!.state!; + const stateBefore = navigationRef.getRootState().routes[0]!.state!; const focusedKeyBefore = stateBefore.routes[stateBefore.index!]!.key; // Guard everything: content hides but the navigator must stay mounted. @@ -659,7 +673,7 @@ describe('all routes guarded', () => { expect(screen.getByTestId('second')).toBeVisible(); expect(screen).toHavePathname('/second'); - const stateAfter = store.state!.routes[0]!.state!; + const stateAfter = navigationRef.getRootState().routes[0]!.state!; expect(stateAfter.routes[stateAfter.index!]!.key).toBe(focusedKeyBefore); // Non-focused guarded history entries are pruned while the guard is down, // so only the focused route survives the flip. diff --git a/packages/expo-router/src/__tests__/push.test.ios.tsx b/packages/expo-router/src/__tests__/push.test.ios.tsx index 6147dca5a91019..b54084e015e225 100644 --- a/packages/expo-router/src/__tests__/push.test.ios.tsx +++ b/packages/expo-router/src/__tests__/push.test.ios.tsx @@ -1,7 +1,7 @@ import { act, screen } from '@testing-library/react-native'; import { Text, View } from 'react-native'; -import { store } from '../global-state/router-store'; +import { navigationRef } from '../global-state/navigationRef'; import { useLocalSearchParams } from '../hooks'; import { router } from '../imperative-api'; import Stack from '../layouts/Stack'; @@ -22,7 +22,7 @@ it('stacks should always push a new route', () => { }); // Initial complete state - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -54,7 +54,7 @@ it('stacks should always push a new route', () => { act(() => router.push('/user/1')); act(() => router.push('/user/2')); - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -302,7 +302,7 @@ it('works in a nested layout Stack->Tab->Stack', () => { testRouter.push('/d'); - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -443,7 +443,7 @@ it('targets the correct Stack when pushing to a nested layout', () => { act(() => router.push('/a')); // Should push to the root stack - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -557,7 +557,7 @@ it('push should also add anchor routes', () => { }); // Initial complete state - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -587,7 +587,7 @@ it('push should also add anchor routes', () => { act(() => router.push('/orange', { withAnchor: true })); - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], diff --git a/packages/expo-router/src/__tests__/redirects.test.ios.tsx b/packages/expo-router/src/__tests__/redirects.test.ios.tsx index a8630515ab9a89..f7a4596fb64e03 100644 --- a/packages/expo-router/src/__tests__/redirects.test.ios.tsx +++ b/packages/expo-router/src/__tests__/redirects.test.ios.tsx @@ -4,9 +4,9 @@ import { Text } from 'react-native'; import type { RedirectConfig } from '../exports'; import { router } from '../exports'; -import type { StoreRedirects } from '../global-state/router-store'; -import { store } from '../global-state/router-store'; -import { StoreContext } from '../global-state/storeContext'; +import { navigationRef } from '../global-state/navigationRef'; +import { RouterConfigContext } from '../global-state/routerConfigContext'; +import type { StoreRedirects } from '../global-state/types'; import Stack from '../layouts/Stack'; import { Tabs } from '../layouts/Tabs'; import { renderRouter } from '../testing-library'; @@ -59,7 +59,7 @@ it('exposes redirects and rewrites through the store context', () => { let contextRedirects: StoreRedirects[] | undefined; function Index() { - contextRedirects = use(StoreContext)!.redirects; + contextRedirects = use(RouterConfigContext)!.redirects; return null; } @@ -96,7 +96,7 @@ it('deep link to a redirect', () => { expect(screen.getByTestId('bar')).toBeTruthy(); - expectCompleteStateToMatch(store.state, { + expectCompleteStateToMatch(navigationRef.getRootState(), { index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -143,7 +143,7 @@ it('deep link to a dynamic redirect', () => { } ); - expectCompleteStateToMatch(store.state, { + expectCompleteStateToMatch(navigationRef.getRootState(), { index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -196,7 +196,7 @@ it('keeps extra params as query params', () => { } ); - expectCompleteStateToMatch(store.state, { + expectCompleteStateToMatch(navigationRef.getRootState(), { index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -243,7 +243,7 @@ it('can redirect from single to catch all', () => { } ); - expectCompleteStateToMatch(store.state, { + expectCompleteStateToMatch(navigationRef.getRootState(), { index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -291,7 +291,7 @@ it('can push to a redirect', () => { bar: () => , }); - expectCompleteStateToMatch(store.state, { + expectCompleteStateToMatch(navigationRef.getRootState(), { index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -321,7 +321,7 @@ it('can push to a redirect', () => { act(() => router.push('/foo')); - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -511,7 +511,7 @@ it('not existing nested route redirects correctly', () => { act(() => router.push('/test/1234')); - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], diff --git a/packages/expo-router/src/__tests__/routerActionState.test.ios.tsx b/packages/expo-router/src/__tests__/routerActionState.test.ios.tsx index 89fcd1bac94494..528ca34e6db914 100644 --- a/packages/expo-router/src/__tests__/routerActionState.test.ios.tsx +++ b/packages/expo-router/src/__tests__/routerActionState.test.ios.tsx @@ -1,6 +1,6 @@ import { act, fireEvent, screen } from '@testing-library/react-native'; -import { store } from '../global-state/router-store'; +import { navigationRef } from '../global-state/navigationRef'; import { useRootNavigationState } from '../hooks'; import { renderHook } from '../hooks/__tests__/renderHook'; import { router } from '../imperative-api'; @@ -34,12 +34,12 @@ it.each([ ['dismissTo', () => router.dismissTo('/(tabs)/deep/1')], ['prefetch', () => router.prefetch('/(tabs)/deep/1')], ['Link press', () => fireEvent.press(screen.getByTestId('deep-link'))], -])('store.state has no marker after %s', (_, navigate) => { +])('root state has no marker after %s', (_, navigate) => { renderRouter(routes); act(navigate); - expectNoMarker(store.state); + expectNoMarker(navigationRef.getRootState()); }); it('useRootNavigationState has no marker', () => { @@ -62,14 +62,14 @@ it('warns and ignores action state without the internal marker', () => { }); act(() => - store.navigationRef.current!.dispatch({ + navigationRef.current!.dispatch({ type: 'NAVIGATE', payload: { name: 'second', state: { routes: [{ name: 'nested' }] } }, }) ); expect(warning).toHaveBeenCalledWith(expect.stringContaining(MARKER)); - const layoutState = store.navigationRef.current!.getRootState().routes[0]!.state!; + const layoutState = navigationRef.current!.getRootState().routes[0]!.state!; expect(layoutState.routes.find((route) => route.name === 'second')?.state).toBeUndefined(); warning.mockRestore(); }); diff --git a/packages/expo-router/src/__tests__/search-params.test.ios.tsx b/packages/expo-router/src/__tests__/search-params.test.ios.tsx index eba40ce532141e..b45b64c5d6f065 100644 --- a/packages/expo-router/src/__tests__/search-params.test.ios.tsx +++ b/packages/expo-router/src/__tests__/search-params.test.ios.tsx @@ -1,6 +1,6 @@ import { screen, act } from '@testing-library/react-native'; -import { store } from '../global-state/router-store'; +import { navigationRef } from '../global-state/navigationRef'; import { router } from '../imperative-api'; import { renderRouter, testRouter } from '../testing-library'; @@ -25,7 +25,7 @@ describe('push', () => { testRouter.push('/page'); // Duplicate pushes are allowed pushes the new '/page' testRouter.push('/page?c=true'); - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -97,7 +97,7 @@ describe('push', () => { testRouter.back(); testRouter.back(); - expect(store.state).toEqual({ + expect(navigationRef.getRootState()).toEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -153,7 +153,7 @@ describe('navigate', () => { testRouter.navigate('/page'); // Will not create new screen are we are already on page testRouter.navigate('/page?c=true'); - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -201,7 +201,7 @@ describe('navigate', () => { testRouter.navigate('/b'); testRouter.navigate('/c'); - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -257,7 +257,7 @@ describe('navigate', () => { testRouter.dismissAll(); - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -307,7 +307,7 @@ describe('replace', () => { testRouter.replace('/page?a=true'); // This will clear the previous route testRouter.push('/page?c=true'); - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], diff --git a/packages/expo-router/src/__tests__/stacks.test.ios.tsx b/packages/expo-router/src/__tests__/stacks.test.ios.tsx index bfe1469c308602..995d399230cb28 100644 --- a/packages/expo-router/src/__tests__/stacks.test.ios.tsx +++ b/packages/expo-router/src/__tests__/stacks.test.ios.tsx @@ -2,7 +2,7 @@ import { act, screen } from '@testing-library/react-native'; import { expectTypeOf } from 'expect-type'; import { Text } from 'react-native'; -import { store } from '../global-state/router-store'; +import { navigationRef } from '../global-state/navigationRef'; import { router } from '../imperative-api'; import Stack from '../layouts/Stack'; import Tabs from '../layouts/Tabs'; @@ -189,7 +189,7 @@ test('dismissAll nested', () => { // The last route should include a sub-state for /one/_layout // It will have three routes (/one/index, /one/page, /one/two) // The last route should include a sub-state for /one/two/_layout - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -304,7 +304,7 @@ test('dismissAll nested', () => { // This should only dismissing the sub-state for /one/two/_layout testRouter.dismissAll(); expect(screen).toHavePathname('/one/two'); - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -407,7 +407,7 @@ test('dismissAll nested', () => { // This should only dismissing the sub-state for /one/_layout testRouter.dismissAll(); expect(screen).toHavePathname('/one'); - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], @@ -593,7 +593,7 @@ describe('singular', () => { } ); - expectCompleteStateToMatch(store.state, { + expectCompleteStateToMatch(navigationRef.getRootState(), { index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], diff --git a/packages/expo-router/src/__tests__/tabs.test.ios.tsx b/packages/expo-router/src/__tests__/tabs.test.ios.tsx index 1a4103fa74a76f..3e5198802e9b5a 100644 --- a/packages/expo-router/src/__tests__/tabs.test.ios.tsx +++ b/packages/expo-router/src/__tests__/tabs.test.ios.tsx @@ -2,7 +2,7 @@ import { fireEvent, act, screen } from '@testing-library/react-native'; import { Text, View } from 'react-native'; import { router } from '../exports'; -import { store } from '../global-state/router-store'; +import { navigationRef } from '../global-state/navigationRef'; import { useLocalSearchParams, useSegments } from '../hooks'; import { Stack } from '../layouts/Stack'; import { Tabs } from '../layouts/Tabs'; @@ -390,7 +390,7 @@ it('can use replace navigation', () => { act(() => router.replace('/two')); expect(screen.getByTestId('two')).toBeVisible(); expect(screen.getByLabelText('two, tab, 2 of 2')).toBeVisible(); - expect(store.state).toStrictEqual({ + expect(navigationRef.getRootState()).toStrictEqual({ index: 0, key: expect.any(String), routeNames: ['__root', '+not-found', '_sitemap'], diff --git a/packages/expo-router/src/fork/NavigationContainer.tsx b/packages/expo-router/src/fork/NavigationContainer.tsx index 90260b3d245818..71da25ad6a0bd1 100644 --- a/packages/expo-router/src/fork/NavigationContainer.tsx +++ b/packages/expo-router/src/fork/NavigationContainer.tsx @@ -1,9 +1,8 @@ import React from 'react'; import { I18nManager } from 'react-native'; +import { RouterConfigContext } from '../global-state/routerConfigContext'; import { RoutingQueueApiContext, RoutingQueueProvider } from '../global-state/routingQueueContext'; -import { syncStoreNavigationState } from '../global-state/store'; -import { StoreContext } from '../global-state/storeContext'; import type { DocumentTitleOptions, LinkingOptions, @@ -75,7 +74,7 @@ function NavigationContainerInner( }: Props, ref?: React.Ref | null> ) { - const store = React.use(StoreContext); + const routerConfig = React.use(RouterConfigContext); if (linking?.config) { validatePathConfig(linking.config); @@ -147,7 +146,6 @@ function NavigationContainerInner( }); const [isResolved, initialState] = useThenable(getInitialState); - React.useImperativeHandle(ref, () => refContainer.current!); if (!isResolved) { @@ -172,8 +170,7 @@ function NavigationContainerInner( onReady={onReadyForLinkingHandling} onStateChange={onStateChangeForLinkingHandling} initialState={initialState} - UNSTABLE_routeNode={store?.routeNode ?? undefined} - UNSTABLE_onStateChangeInsertion={store ? syncStoreNavigationState : undefined} + UNSTABLE_routeNode={routerConfig?.routeNode ?? undefined} ref={refContainer} /> diff --git a/packages/expo-router/src/fork/__tests__/__fixtures__/store.tsx b/packages/expo-router/src/fork/__tests__/__fixtures__/store.tsx index 02fd81e72e467d..c4ca667966177a 100644 --- a/packages/expo-router/src/fork/__tests__/__fixtures__/store.tsx +++ b/packages/expo-router/src/fork/__tests__/__fixtures__/store.tsx @@ -9,27 +9,22 @@ import { RoutingQueueProvider, } from '../../../global-state/routingQueueContext'; import type { RoutingIntent } from '../../../global-state/routingQueue'; -import { storeRef } from '../../../global-state/store'; -import { StoreContext, type StoreContextValue } from '../../../global-state/storeContext'; +import type { RouteNode } from '../../../Route'; +import { defaultRouteInfo, getRouteInfoFromState } from '../../../global-state/getRouteInfoFromState'; +import { RouteInfoContext } from '../../../global-state/routeInfoContext'; +import { RouterConfigContext } from '../../../global-state/routerConfigContext'; +import type { NavigationState } from '../../../react-navigation/routers'; -function EmptyScreen() { - return null; +let routeNode: RouteNode | null = null; +let navigationState: NavigationState | undefined; + +export function setRouteNode(value: RouteNode | null) { + routeNode = value; } -export const storeValue: StoreContextValue = { - get navigationRef() { - return storeRef.current.navigationRef; - }, - linking: undefined, - get state() { - return storeRef.current.state; - }, - rootComponent: EmptyScreen, - get routeNode() { - return storeRef.current.routeNode; - }, - redirects: [], -}; +export function setNavigationState(value: NavigationState | undefined) { + navigationState = value; +} let pendingIntents: RoutingIntent[] = []; @@ -43,10 +38,16 @@ export function getPendingIntents() { } export function StoreProvider({ children }: { children: ReactNode }) { + const routeInfo = + navigationState?.routes[0]?.name === '__root' + ? getRouteInfoFromState(navigationState) + : defaultRouteInfo; return ( - {children} - + + {children} + + ); } diff --git a/packages/expo-router/src/fork/__tests__/useLinking.test.ios.tsx b/packages/expo-router/src/fork/__tests__/useLinking.test.ios.tsx index 67d17909c5abd4..4bf125c2eec136 100644 --- a/packages/expo-router/src/fork/__tests__/useLinking.test.ios.tsx +++ b/packages/expo-router/src/fork/__tests__/useLinking.test.ios.tsx @@ -3,11 +3,15 @@ import { act, type RenderAPI } from '@testing-library/react-native'; import { Text } from 'react-native'; import { node } from '../../global-state/__tests__/__fixtures__/routeNode'; -import { store, storeRef as mockStoreRef } from '../../global-state/store'; +import { completeParsedState } from '../../global-state/createSeededNavigationState'; +import { getRouteInfoFromState } from '../../global-state/getRouteInfoFromState'; +import { getStateFromPath } from '../../link/linking'; import { createNavigationContainerRef, type ParamListBase } from '../../react-navigation/core'; +import { ROOT_CHAIN } from '../../react-navigation/routers/stateKeys'; +import { getMockConfig } from '../../testing-library/mock-config'; import { NavigationContainer } from '../NavigationContainer'; import { useLinking } from '../useLinking'; -import { getPendingIntents, render, renderHook } from './__fixtures__/store'; +import { getPendingIntents, render, renderHook, setRouteNode } from './__fixtures__/store'; let errorSpy: jest.SpiedFunction | undefined; @@ -23,8 +27,7 @@ function getParsedHomeState() { } beforeEach(() => { - mockStoreRef.current.routeNode = node('root', [node('home', [node('[id]')])]); - mockStoreRef.current.state = undefined; + setRouteNode(node('root', [node('home', [node('[id]')])])); }); afterEach(() => { @@ -35,7 +38,7 @@ test('queues an incoming deep link using its extracted app path', () => { const ref = createNavigationContainerRef(); // Only `getRootState` is used by the linking subscription. ref.current = { - getRootState: () => ({ routeNames: ['home'] }), + getRootState: () => ({ routeNames: ['home'], routes: [{ name: '__root' }] }), } as typeof ref.current; let listener: ((url: string) => void) | undefined; const getStateFromPath = jest.fn(() => ({ routes: [{ name: 'home' }] })); @@ -72,11 +75,51 @@ test('queues an incoming deep link using its extracted app path', () => { ]); }); +test('keeps the current route group when parsing an incoming deep link', () => { + const config = getMockConfig(['(a)/shared', '(b)/shared', '(a)/index', '(b)/other']); + const currentState = completeParsedState( + getStateFromPath('/other', config, ['(b)', 'other']), + ROOT_CHAIN + ); + expect(getRouteInfoFromState(currentState).segments).toEqual(['(b)', 'other']); + const ref = createNavigationContainerRef(); + ref.current = { + getRootState: () => currentState, + } as typeof ref.current; + let listener: ((url: string) => void) | undefined; + const parsePath = jest.fn(getStateFromPath); + + function Sample() { + useLinking( + ref, + { + prefixes: ['example://'], + config, + getStateFromPath: parsePath, + subscribe: (nextListener) => { + listener = nextListener; + return () => {}; + }, + }, + () => {} + ); + return null; + } + + render(); + act(() => listener?.('example://shared')); + + expect(parsePath).toHaveBeenCalledWith('shared', config, ['(b)', 'other']); + expect( + getRouteInfoFromState(getStateFromPath('/shared', config, ['(b)', 'other'])).segments + ).toEqual(['(b)', 'shared']); +}); + test('reports an incoming deep link using its extracted app path', () => { const ref = createNavigationContainerRef(); // Only `getRootState` is used by the linking subscription. ref.current = { - getRootState: () => ({ routeNames: ['home'] }), + getRootState: () => ({ routeNames: ['home'], routes: [{ name: '__root' }] }), } as typeof ref.current; let listener: ((url: string) => void) | undefined; const onUnhandledLinking = jest.fn(); @@ -106,7 +149,7 @@ test('reports an incoming deep link using its extracted app path', () => { }); }); -test('resolves a completed state from an async initial URL without writing to the store', async () => { +test('resolves a completed state from an async initial URL', async () => { const ref = createNavigationContainerRef(); const getStateFromPath = jest.fn(() => ({ routes: [ @@ -145,14 +188,13 @@ test('resolves a completed state from an async initial URL without writing to th key: expect.any(String), routeNames: ['[id]'], }); - expect(mockStoreRef.current.state).toBeUndefined(); }); test('resubscribes on re-render and cleans up the previous subscription', () => { const ref = createNavigationContainerRef(); // Only `getRootState` is used by the linking subscription. ref.current = { - getRootState: () => ({ routeNames: ['home'] }), + getRootState: () => ({ routeNames: ['home'], routes: [{ name: '__root' }] }), } as typeof ref.current; const listeners: ((url: string) => void)[] = []; const unsubscribes = [jest.fn(), jest.fn()]; @@ -215,9 +257,11 @@ test('async initial URL is parsed with first-render options', async () => { expect(secondGetStateFromPath).not.toHaveBeenCalled(); }); -test('does not reseed the store when it already holds the seeded state', () => { +test('preserves seeded state on rerender', () => { + const ref = createNavigationContainerRef(); const element = render( 'example://home', @@ -226,10 +270,11 @@ test('does not reseed the store when it already holds the seeded state', () => { {null} ); - const seededState = mockStoreRef.current.state; + const seededState = ref.getRootState(); element.rerender( 'example://home', @@ -239,7 +284,7 @@ test('does not reseed the store when it already holds the seeded state', () => { ); - expect(mockStoreRef.current.state).toBe(seededState); + expect(ref.getRootState()).toBe(seededState); }); test('renders children on first paint with a synchronous initial URL and no initialState prop', () => { @@ -280,16 +325,18 @@ test('shows fallback then content for an async initial URL', async () => { expect(element.getByTestId('content')).toBeTruthy(); }); -test('seeds the store when a synchronous initial URL is absent', () => { +test('seeds navigation state when a synchronous initial URL is absent', () => { + const ref = createNavigationContainerRef(); render( null }}> {null} ); - expect(mockStoreRef.current.state).toMatchObject({ + expect(ref.getRootState()).toMatchObject({ stale: false, routeKeySeq: expect.any(Number), routeNames: ['__root', '+not-found', '_sitemap'], @@ -300,11 +347,11 @@ test('seeds the store when a synchronous initial URL is absent', () => { }, ], }); - expect(store.getRouteInfo().pathname).toBe('/home'); + expect(getRouteInfoFromState(ref.getRootState()).pathname).toBe('/home'); }); test('throws when linking does not produce an initial state', () => { - mockStoreRef.current.routeNode = null; + setRouteNode(null); expect(() => render( diff --git a/packages/expo-router/src/fork/__tests__/useLinking.test.web.tsx b/packages/expo-router/src/fork/__tests__/useLinking.test.web.tsx index 2df7ba96f2b33b..048b054e4dbb8f 100644 --- a/packages/expo-router/src/fork/__tests__/useLinking.test.web.tsx +++ b/packages/expo-router/src/fork/__tests__/useLinking.test.web.tsx @@ -6,7 +6,6 @@ import { node } from '../../global-state/__tests__/__fixtures__/routeNode'; import { completeParsedState } from '../../global-state/createSeededNavigationState'; import { getRouteInfoFromState } from '../../global-state/getRouteInfoFromState'; import { RouterRegistryProvider } from '../../global-state/routerRegistry'; -import { storeRef as mockStoreRef } from '../../global-state/store'; import { getRootStackRouteNames } from '../../global-state/utils'; import { getStateFromPath } from '../../link/linking'; import { Screen } from '../../react-navigation/core/Screen'; @@ -18,7 +17,7 @@ import { getMockConfig } from '../../testing-library/mock-config'; import { NavigationContainer } from '../NavigationContainer'; import { createMemoryHistory } from '../createMemoryHistory'; import { useLinking } from '../useLinking'; -import { getPendingIntents, render } from './__fixtures__/store'; +import { getPendingIntents, render, setNavigationState, setRouteNode } from './__fixtures__/store'; jest.mock('../createMemoryHistory'); let mockNavigationRef: ReturnType; @@ -44,8 +43,8 @@ function EmptyScreen() { } beforeEach(() => { - mockStoreRef.current.state = undefined; - mockStoreRef.current.routeNode = null; + setNavigationState(undefined); + setRouteNode(null); jest.mocked(getRootStackRouteNames).mockReturnValue(['home']); jest.mocked(createMemoryHistory).mockReturnValue(history); Object.defineProperty(globalThis, 'location', { @@ -75,7 +74,14 @@ function renderHistoryListener({ }); const navigation = { addListener: jest.fn(() => () => {}), - getRootState: jest.fn(() => ({ key: 'root' })), + getRootState: jest.fn(() => ({ + stale: false as const, + routeKeySeq: 0, + key: 'root', + index: 0, + routeNames: ['home'], + routes: [{ key: '__root', name: '__root' }], + })), }; // The hook only reads these two methods from the navigation ref in these tests. const ref = { current: navigation } as unknown as Parameters[0]; @@ -97,7 +103,7 @@ function renderHistoryListener({ } test('queues forward history navigation', () => { - mockStoreRef.current.routeNode = mockRouteNode; + setRouteNode(mockRouteNode); const getStateFromPath = jest.fn(() => ({ routes: [{ name: 'home' }] })); const { emitPopState } = renderHistoryListener({ initialIndex: 3, getStateFromPath }); @@ -145,7 +151,7 @@ test('restores saved history state without parsing its path', () => { }); test('restores state parsed from a history path', () => { - mockStoreRef.current.routeNode = mockRouteNode; + setRouteNode(mockRouteNode); const parsedState = { routes: [{ name: 'home' }] }; const getStateFromPath = jest.fn(() => parsedState); const { emitPopState } = renderHistoryListener({ initialIndex: 3, getStateFromPath }); @@ -210,14 +216,14 @@ test('keeps the current route group when parsing a popstate path', () => { jest .mocked(getRootStackRouteNames) .mockReturnValue(parsedSharedState?.routes.map((route) => route.name) ?? []); - mockStoreRef.current.state = completeParsedState( + const currentState = completeParsedState( getStateFromPath('/other', config, ['(b)', 'other']), ROOT_CHAIN ); - expect(getRouteInfoFromState(mockStoreRef.current.state).segments).toEqual(['(b)', 'other']); + expect(getRouteInfoFromState(currentState).segments).toEqual(['(b)', 'other']); const navigation = { addListener: jest.fn(() => () => {}), - getRootState: jest.fn(() => ({ key: 'root' })), + getRootState: jest.fn(() => currentState), }; // The hook only reads these two methods from the navigation ref in this test. const ref = { current: navigation } as unknown as Parameters[0]; @@ -250,8 +256,8 @@ test('keeps the current route group when parsing a popstate path', () => { expect(getRouteInfoFromState(parsedState as NavigationState).segments).toEqual(['(b)', 'shared']); }); -test('parses the initial URL instead of returning the existing store state', async () => { - mockStoreRef.current.routeNode = mockRouteNode; +test('parses the initial URL instead of returning existing navigation state', async () => { + setRouteNode(mockRouteNode); const existingState = { stale: false as const, routeKeySeq: 0, @@ -261,7 +267,7 @@ test('parses the initial URL instead of returning the existing store state', asy routes: [{ key: 'home', name: 'home' }], }; mockNavigationRef = createNavigationContainerRef(); - mockStoreRef.current.state = existingState; + setNavigationState(existingState); Object.assign(globalThis.location, { pathname: '/home', search: '', hash: '' }); let getInitialState: ReturnType['getInitialState'] | undefined; const getStateFromPath = jest.fn(() => ({ routes: [{ name: 'home' }] })); @@ -289,7 +295,7 @@ test('parses the initial URL instead of returning the existing store state', asy }); test('getInitialState is computed once with first-render options', async () => { - mockStoreRef.current.routeNode = mockRouteNode; + setRouteNode(mockRouteNode); mockNavigationRef = createNavigationContainerRef(); Object.assign(globalThis.location, { pathname: '/home', search: '', hash: '' }); const firstGetStateFromPath = jest.fn(() => ({ routes: [{ name: 'home' }] })); @@ -335,14 +341,6 @@ test('does not add browser history when preloading a stack route', async () => { }; const ref = createNavigationContainerRef(); mockNavigationRef = ref; - mockStoreRef.current.state = { - stale: false, - routeKeySeq: 0, - key: 'root', - index: 0, - routeNames: ['home', 'details'], - routes: [{ key: 'home', name: 'home' }], - }; const onStateChange = jest.fn(); render( diff --git a/packages/expo-router/src/fork/useLinking.native.ts b/packages/expo-router/src/fork/useLinking.native.ts index 9a001717f4f94f..f080dad2119638 100644 --- a/packages/expo-router/src/fork/useLinking.native.ts +++ b/packages/expo-router/src/fork/useLinking.native.ts @@ -6,8 +6,8 @@ import { createSeededRootState, } from '../global-state/createSeededNavigationState'; import { getRouteInfoFromState } from '../global-state/getRouteInfoFromState'; +import { RouterConfigContext } from '../global-state/routerConfigContext'; import { useEnqueueRoutingIntent } from '../global-state/routingQueueContext'; -import { StoreContext } from '../global-state/storeContext'; import { type LinkingOptions, getStateFromPath as getStateFromPathDefault, @@ -59,7 +59,7 @@ export function useLinking( }: Options, onUnhandledLinking: (lastUnhandledLining: string | undefined) => void ) { - const store = use(StoreContext); + const routerConfig = use(RouterConfigContext); const enqueue = useEnqueueRoutingIntent(); useEffect(() => { @@ -105,21 +105,23 @@ export function useLinking( getStateFromPathRef.current = getStateFromPath; }); - const getStateFromURL = useCallback((url: string | null | undefined) => { - if (!url || (filterRef.current && !filterRef.current(url))) { - return undefined; - } - - const path = extractExpoPathFromURL(prefixesRef.current, url); + const getStateFromURL = useCallback( + (url: string | null | undefined) => { + if (!url || (filterRef.current && !filterRef.current(url))) { + return undefined; + } - return path !== undefined - ? getStateFromPathRef.current( - path, - configRef.current, - getRouteInfoFromState(store?.state).segments - ) - : undefined; - }, []); + const path = extractExpoPathFromURL(prefixesRef.current, url); + if (path !== undefined) { + // TODO(@ubax): check if this is performant + // TODO(@ubax): check if ref.current?.getRootState() can be replaced with the context read + const segments = getRouteInfoFromState(ref.current?.getRootState()).segments; + return getStateFromPathRef.current(path, configRef.current, segments); + } + return undefined; + }, + [ref] + ); const getInitialState = useCallback(() => { const url = getInitialURL(); @@ -130,7 +132,7 @@ export function useLinking( parsedState = getStateFromPath(path, config); } - const routeNode = store?.routeNode; + const routeNode = routerConfig?.routeNode; return routeNode ? createSeededRootState(parsedState, routeNode) : completeParsedState(parsedState, ROOT_CHAIN); @@ -165,7 +167,7 @@ export function useLinking( }; return thenable as PromiseLike; - }, [config, filter, getInitialURL, getStateFromPath, onUnhandledLinking, prefixes, store]); + }, [config, filter, getInitialURL, getStateFromPath, onUnhandledLinking, prefixes, routerConfig]); useEffect(() => { const listener = (url: string) => { diff --git a/packages/expo-router/src/fork/useLinking.ts b/packages/expo-router/src/fork/useLinking.ts index 53b4432d1adca7..aeab01aebc25b3 100644 --- a/packages/expo-router/src/fork/useLinking.ts +++ b/packages/expo-router/src/fork/useLinking.ts @@ -6,9 +6,9 @@ import { createSeededRootState, } from '../global-state/createSeededNavigationState'; import { getRouteInfoFromState } from '../global-state/getRouteInfoFromState'; +import { RouterConfigContext } from '../global-state/routerConfigContext'; import type { RoutingIntent } from '../global-state/routingQueue'; import { useEnqueueRoutingIntent } from '../global-state/routingQueueContext'; -import { StoreContext } from '../global-state/storeContext'; import { getRootStackRouteNames } from '../global-state/utils'; import { type LinkingOptions, @@ -44,7 +44,7 @@ export function useLinking( }: Options, onUnhandledLinking: (lastUnhandledLining: string | undefined) => void ) { - const store = use(StoreContext); + const routerConfig = use(RouterConfigContext); useEffect(() => { if (process.env.NODE_ENV === 'production') { @@ -85,7 +85,7 @@ export function useLinking( } const parsedState = path ? getStateFromPath(path, config) : undefined; - const routeNode = store?.routeNode; + const routeNode = routerConfig?.routeNode; const state = routeNode ? createSeededRootState(parsedState, routeNode) : completeParsedState(parsedState, ROOT_CHAIN); @@ -180,7 +180,7 @@ function useBrowserHistorySync({ getPathFromState: GetPathFromState; onUnhandledLinking: (path: string | undefined) => void; }) { - const store = use(StoreContext); + const routerConfig = use(RouterConfigContext); const enqueue = useEnqueueRoutingIntent(); const [history] = useState(createMemoryHistory); const configRef = useRef(config); @@ -241,19 +241,17 @@ function useBrowserHistorySync({ return; } - const parsedState = getStateFromPathRef.current( - path, - configRef.current, - getRouteInfoFromState(store?.state).segments - ); + // TODO(@ubax): check if navigation.getRootState() can be replaced with the context read + const segments = getRouteInfoFromState(navigation.getRootState()).segments; + const parsedState = getStateFromPathRef.current(path, configRef.current, segments); if (parsedState) { onUnhandledLinking(path); const routeNames = getRootStackRouteNames(); if (parsedState.routes.some((route) => !routeNames.includes(route.name))) { return; } - const state = store?.routeNode - ? createSeededRootState(parsedState, store.routeNode) + const state = routerConfig?.routeNode + ? createSeededRootState(parsedState, routerConfig.routeNode) : completeParsedState(parsedState, ROOT_CHAIN); if (!state) { return; @@ -326,13 +324,12 @@ function useBrowserHistorySync({ }; if (ref.current) { + // TODO(@ubax): check if navigation.getRootState() can be replaced with the context read const rootState = ref.current.getRootState(); - const state = store?.state as NavigationState | undefined; - - if (state) { - const path = getPathForRoute(findFocusedRoute(state), state); + if (rootState) { + const path = getPathForRoute(findFocusedRoute(rootState), rootState); previousStateRef.current ??= rootState; - history.replace({ path, state }); + history.replace({ path, state: rootState }); } } @@ -344,14 +341,13 @@ function useBrowserHistorySync({ } const previousState = previousStateRef.current; + // TODO(@ubax): check if navigation.getRootState() can be replaced with the context read const rootState = navigation.getRootState(); - const state = store?.state as NavigationState | undefined; - - if (!state) { + if (!rootState) { return; } - const path = getPathForRoute(findFocusedRoute(state), state); + const path = getPathForRoute(findFocusedRoute(rootState), rootState); let pendingOperation: { path: string } | undefined; // React may batch multiple queued actions into one state event, so use the latest match. @@ -368,14 +364,14 @@ function useBrowserHistorySync({ } previousStateRef.current = rootState; - const [previousFocusedState, focusedState] = findMatchingState(previousState, state); + const [previousFocusedState, focusedState] = findMatchingState(previousState, rootState); if (previousFocusedState && focusedState && !pendingOperation) { const historyDelta = getHistoryLength(focusedState) - getHistoryLength(previousFocusedState); if (historyDelta > 0) { - history.push({ path, state }); + history.push({ path, state: rootState }); } else if (historyDelta < 0) { const nextIndex = history.backIndex({ path }); const currentIndex = history.index; @@ -391,15 +387,15 @@ function useBrowserHistorySync({ await history.go(historyDelta); } - history.replace({ path, state }); + history.replace({ path, state: rootState }); } catch { // The navigation was interrupted. } } else { - history.replace({ path, state }); + history.replace({ path, state: rootState }); } } else { - history.replace({ path, state }); + history.replace({ path, state: rootState }); } }; diff --git a/packages/expo-router/src/getLinkingConfig.ts b/packages/expo-router/src/getLinkingConfig.ts index 52b7526a5cffc1..aea7837fda7969 100644 --- a/packages/expo-router/src/getLinkingConfig.ts +++ b/packages/expo-router/src/getLinkingConfig.ts @@ -5,7 +5,7 @@ import { INTERNAL_SLOT_NAME, NOT_FOUND_ROUTE_NAME, SITEMAP_ROUTE_NAME } from './ import type { State } from './fork/getPathFromState'; import { getReactNavigationConfig } from './getReactNavigationConfig'; import { applyRedirects } from './getRoutesRedirects'; -import type { StoreRedirects } from './global-state/router-store'; +import type { StoreRedirects } from './global-state/types'; import { getInitialURL, getPathFromState, getStateFromPath, subscribe } from './link/linking'; import type { LinkingOptions } from './react-navigation/native'; import type { NativeIntent, RequireContext } from './types'; diff --git a/packages/expo-router/src/getRoutesRedirects.tsx b/packages/expo-router/src/getRoutesRedirects.tsx index 5acb171a5c67ec..0e292b17690167 100644 --- a/packages/expo-router/src/getRoutesRedirects.tsx +++ b/packages/expo-router/src/getRoutesRedirects.tsx @@ -3,7 +3,7 @@ import { createElement, useEffect } from 'react'; import { cleanPath } from './fork/getStateFromPath-forks'; import type { RedirectConfig } from './getRoutesCore'; -import type { StoreRedirects } from './global-state/router-store'; +import type { StoreRedirects } from './global-state/types'; import { matchDynamicName } from './matchers'; import { shouldLinkExternally } from './utils/url'; diff --git a/packages/expo-router/src/global-state/__tests__/router.test.ios.ts b/packages/expo-router/src/global-state/__tests__/router.test.ios.ts index 4cf91de23cb0ce..608a9a18c93b3c 100644 --- a/packages/expo-router/src/global-state/__tests__/router.test.ios.ts +++ b/packages/expo-router/src/global-state/__tests__/router.test.ios.ts @@ -1,6 +1,7 @@ import * as Linking from 'expo-linking'; import { emitDomDismiss, emitDomDismissAll, emitDomGoBack } from '../../domComponents/emitDomEvent'; +import { navigationRef } from '../navigationRef'; import { canDismiss, canGoBack, @@ -18,25 +19,18 @@ import { router, setParams, } from '../router'; -import { store } from '../store'; - -jest.mock('../store', () => ({ - store: { - assertIsReady: jest.fn(), - navigationRef: { - isReady: jest.fn(() => true), - current: { - canGoBack: jest.fn(), - setParams: jest.fn(), - goBack: jest.fn(), - getRootState: jest.fn(), - dispatch: jest.fn(), - }, + +jest.mock('../navigationRef', () => ({ + navigationRef: { + isReady: jest.fn(() => true), + getRootState: jest.fn(), + current: { + canGoBack: jest.fn(), + setParams: jest.fn(), + goBack: jest.fn(), + getRootState: jest.fn(), + dispatch: jest.fn(), }, - state: undefined as any, - linking: { getStateFromPath: jest.fn(), config: {} }, - getRouteInfo: jest.fn(() => ({ pathname: '/', segments: [], params: {} })), - redirects: [], }, })); @@ -66,7 +60,8 @@ const mockEmitDomDismissAll = emitDomDismissAll as jest.Mock; const mockEmitDomGoBack = emitDomGoBack as jest.Mock; beforeEach(() => { jest.clearAllMocks(); - (store as any).state = undefined; + (navigationRef.isReady as jest.Mock).mockReturnValue(true); + (navigationRef.getRootState as jest.Mock).mockReturnValue(undefined); }); it('throws before the module-level router is installed', () => { @@ -77,30 +72,30 @@ it('throws before the module-level router is installed', () => { describe('canDismiss', () => { it('returns false when state is undefined', () => { - (store as any).state = undefined; + (navigationRef.isReady as jest.Mock).mockReturnValue(false); expect(canDismiss()).toBe(false); }); it('returns false for single-route stack', () => { - (store as any).state = { + (navigationRef.getRootState as jest.Mock).mockReturnValue({ type: 'stack', routes: [{ name: 'home' }], index: 0, - }; + }); expect(canDismiss()).toBe(false); }); it('returns true for stack with >1 routes', () => { - (store as any).state = { + (navigationRef.getRootState as jest.Mock).mockReturnValue({ type: 'stack', routes: [{ name: 'home' }, { name: 'detail' }], index: 1, - }; + }); expect(canDismiss()).toBe(true); }); it('traverses nested navigators (tab โ†’ stack with 2 routes โ†’ true)', () => { - (store as any).state = { + (navigationRef.getRootState as jest.Mock).mockReturnValue({ type: 'tab', routes: [ { @@ -113,20 +108,20 @@ describe('canDismiss', () => { }, ], index: 0, - }; + }); expect(canDismiss()).toBe(true); }); it('returns false when index is undefined in state', () => { - (store as any).state = { + (navigationRef.getRootState as jest.Mock).mockReturnValue({ type: 'tab', routes: [{ name: 'tab1' }], - }; + }); expect(canDismiss()).toBe(false); }); it('returns false for non-stack navigator with single route', () => { - (store as any).state = { + (navigationRef.getRootState as jest.Mock).mockReturnValue({ type: 'tab', routes: [ { @@ -139,12 +134,12 @@ describe('canDismiss', () => { }, ], index: 0, - }; + }); expect(canDismiss()).toBe(false); }); it('traverses deeply nested navigators (tab โ†’ stack โ†’ tab โ†’ stack with 2 routes)', () => { - (store as any).state = { + (navigationRef.getRootState as jest.Mock).mockReturnValue({ type: 'tab', routes: [ { @@ -175,7 +170,7 @@ describe('canDismiss', () => { }, ], index: 0, - }; + }); expect(canDismiss()).toBe(true); }); }); @@ -214,7 +209,7 @@ describe('linkTo', () => { type: 'ACTION', payload: { action: { type: 'GO_BACK' } }, }); - expect(store.navigationRef.current!.goBack).not.toHaveBeenCalled(); + expect(navigationRef.current!.goBack).not.toHaveBeenCalled(); }); it('queues GO_BACK for ../ href', () => { @@ -224,7 +219,7 @@ describe('linkTo', () => { type: 'ACTION', payload: { action: { type: 'GO_BACK' } }, }); - expect(store.navigationRef.current!.goBack).not.toHaveBeenCalled(); + expect(navigationRef.current!.goBack).not.toHaveBeenCalled(); }); it('resolves object hrefs via resolveHref', () => { @@ -337,7 +332,7 @@ describe('router action functions', () => { it('goBack enqueues GO_BACK without requiring the container to be ready', () => { goBack(); - expect(store.navigationRef.isReady).not.toHaveBeenCalled(); + expect(navigationRef.isReady).not.toHaveBeenCalled(); expect(mockAdd).toHaveBeenCalledWith({ type: 'ACTION', payload: { action: { type: 'GO_BACK' } }, @@ -349,23 +344,23 @@ describe('router action functions', () => { }); it('canGoBack returns false when navigation not ready', () => { - (store.navigationRef.isReady as jest.Mock).mockReturnValueOnce(false); + (navigationRef.isReady as jest.Mock).mockReturnValueOnce(false); expect(canGoBack()).toBe(false); }); it('canGoBack delegates to navigationRef.current.canGoBack()', () => { - (store.navigationRef.current!.canGoBack as jest.Mock).mockReturnValueOnce(true); + (navigationRef.current!.canGoBack as jest.Mock).mockReturnValueOnce(true); expect(canGoBack()).toBe(true); - expect(store.navigationRef.current!.canGoBack).toHaveBeenCalled(); + expect(navigationRef.current!.canGoBack).toHaveBeenCalled(); }); it('setParams checks navigation readiness', () => { setParams({ name: 'test' }); - expect(store.navigationRef.isReady).toHaveBeenCalled(); - expect(store.navigationRef.current!.setParams).toHaveBeenCalledWith({ name: 'test' }); + expect(navigationRef.isReady).toHaveBeenCalled(); + expect(navigationRef.current!.setParams).toHaveBeenCalledWith({ name: 'test' }); }); }); @@ -395,6 +390,6 @@ describe('DOM short-circuit paths', () => { expect(mockEmitDomGoBack).toHaveBeenCalled(); expect(mockAdd).not.toHaveBeenCalled(); - expect(store.navigationRef.isReady).not.toHaveBeenCalled(); + expect(navigationRef.isReady).not.toHaveBeenCalled(); }); }); diff --git a/packages/expo-router/src/global-state/__tests__/routerRegistry.test.ios.tsx b/packages/expo-router/src/global-state/__tests__/routerRegistry.test.ios.tsx index 3757d376fc325b..560914526d498c 100644 --- a/packages/expo-router/src/global-state/__tests__/routerRegistry.test.ios.tsx +++ b/packages/expo-router/src/global-state/__tests__/routerRegistry.test.ios.tsx @@ -8,7 +8,7 @@ import { router } from '../../imperative-api'; import Stack from '../../layouts/Stack'; import { StackActions, type NavigationState } from '../../react-navigation/native'; import { getMockContext, renderRouter } from '../../testing-library'; -import { store } from '../router-store'; +import { navigationRef } from '../navigationRef'; import { RouterRegistryProvider, RouterRegistryContext, @@ -46,7 +46,7 @@ function collectStateKeys(state: NavigationState): string[] { } function getLayoutState(): NavigationState { - const layoutState = store.navigationRef.current!.getRootState().routes[0]!.state; + const layoutState = navigationRef.current!.getRootState().routes[0]!.state; if (layoutState?.stale !== false) { throw new Error('Expected initialized layout state'); @@ -190,7 +190,7 @@ describe('navigation builder registration', () => { index: Probe, }); - const rootState = store.navigationRef.current!.getRootState(); + const rootState = navigationRef.current!.getRootState(); const layoutState = rootState.routes[0]!.state!; expect([...registry.keys()]).toEqual(expect.arrayContaining([rootState.key, layoutState.key])); @@ -289,12 +289,12 @@ describe('navigation builder registration', () => { ); - const initialKeys = collectStateKeys(store.navigationRef.current!.getRootState()); + const initialKeys = collectStateKeys(navigationRef.current!.getRootState()); const initialMounts = mounts; act(() => rerenderLayout()); - expect(collectStateKeys(store.navigationRef.current!.getRootState())).toEqual(initialKeys); + expect(collectStateKeys(navigationRef.current!.getRootState())).toEqual(initialKeys); expect(mounts).toBe(initialMounts); }); diff --git a/packages/expo-router/src/global-state/__tests__/store.test.ios.ts b/packages/expo-router/src/global-state/__tests__/store.test.ios.ts deleted file mode 100644 index f9fc4918b22748..00000000000000 --- a/packages/expo-router/src/global-state/__tests__/store.test.ios.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { INTERNAL_SLOT_NAME } from '../../constants'; -import { store, storeRef, syncStoreNavigationState } from '../store'; -import type { ReactNavigationState } from '../types'; - -const state: ReactNavigationState = { - stale: false, - routeKeySeq: 0, - key: 'root', - index: 0, - routeNames: [INTERNAL_SLOT_NAME], - routes: [ - { - key: 'slot', - name: INTERNAL_SLOT_NAME, - state: { - stale: false, - routeKeySeq: 0, - key: 'layout', - index: 0, - routeNames: ['index'], - routes: [{ key: 'index', name: 'index', path: '/' }], - }, - }, - ], -}; - -const secondState: ReactNavigationState = { - ...state, - key: 'second-root', - routes: [ - { - key: 'second-slot', - name: INTERNAL_SLOT_NAME, - state: { - stale: false, - routeKeySeq: 0, - key: 'second-layout', - index: 0, - routeNames: ['second'], - routes: [{ key: 'second', name: 'second', path: '/second' }], - }, - }, - ], -}; - -afterEach(() => { - storeRef.current.state = undefined; -}); - -it('reads route info from the live store ref', () => { - syncStoreNavigationState(state); - storeRef.current.state = secondState; - - expect(store.getRouteInfo().pathname).toBe('/second'); -}); - -it('memoizes route info for the current state reference', () => { - syncStoreNavigationState(state); - - const first = store.getRouteInfo(); - expect(store.getRouteInfo()).toBe(first); - - syncStoreNavigationState({ ...state }); - expect(store.getRouteInfo()).not.toBe(first); -}); - -it('logs an error for stale focused state', () => { - const error = jest.spyOn(console, 'error').mockImplementation(() => {}); - const nodeEnv = process.env.NODE_ENV; - process.env.NODE_ENV = 'development'; - // `ReactNavigationState` permits partial input, so this creates invalid runtime state deliberately. - const staleState = { - ...state, - routes: [ - { - ...state.routes[0]!, - state: { ...state.routes[0]!.state!, stale: true }, - }, - ], - } as ReactNavigationState; - - syncStoreNavigationState(staleState); - - expect(error).toHaveBeenCalledWith('Detected stale state. This is likely a bug in Expo Router.'); - process.env.NODE_ENV = nodeEnv; - error.mockRestore(); -}); diff --git a/packages/expo-router/src/global-state/__tests__/useNavigationTreeReducer.test.ios.tsx b/packages/expo-router/src/global-state/__tests__/useNavigationTreeReducer.test.ios.tsx index 06dbf81aa297cd..1d81255107834a 100644 --- a/packages/expo-router/src/global-state/__tests__/useNavigationTreeReducer.test.ios.tsx +++ b/packages/expo-router/src/global-state/__tests__/useNavigationTreeReducer.test.ios.tsx @@ -29,17 +29,16 @@ const initialState: NavigationState = { function renderReducer({ registry, - onStateChangeInsertion = jest.fn(), + state = initialState, }: { registry: RouterRegistry; - onStateChangeInsertion?: (state: NavigationState) => void; + state?: NavigationState; }) { return renderHook, { registry: RouterRegistry }>( ({ registry }) => useNavigationTreeReducer({ - initialState, + initialState: state, registry, - onStateChangeInsertion, }), { initialProps: { registry } } ); @@ -50,10 +49,8 @@ it('reduces consecutive actions against accumulated state with one committed upd state: { ...state, index: state.index + 1 }, affectedRouteKey: state.routes[state.index + 1]!.key, })); - const onStateChangeInsertion = jest.fn(); const result = renderReducer({ registry: new Map([['root', entry(reduce)]]), - onStateChangeInsertion, }); act(() => { @@ -64,7 +61,59 @@ it('reduces consecutive actions against accumulated state with one committed upd expect(reduce).toHaveBeenCalledTimes(2); expect(reduce.mock.calls[1]![0].index).toBe(1); expect(result.result.current.state.index).toBe(2); - expect(onStateChangeInsertion).toHaveBeenCalledTimes(2); +}); + +it('logs an error for stale focused state after commit', () => { + const error = jest.spyOn(console, 'error').mockImplementation(() => {}); + const nodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + const reduce = jest.fn((state: NavigationState) => { + // `NavigationState` excludes stale committed state, which this test deliberately creates. + const staleState = { + ...state, + routes: [{ ...state.routes[0]!, state: { ...state, stale: true as const } }], + } as unknown as NavigationState; + return { state: staleState, affectedRouteKey: state.routes[0]!.key }; + }); + const result = renderReducer({ registry: new Map([['root', entry(reduce)]]) }); + + act(() => result.result.current.handleAction({ type: 'STALE' })); + + expect(error).toHaveBeenCalledWith('Detected stale state. This is likely a bug in Expo Router.'); + process.env.NODE_ENV = nodeEnv; + error.mockRestore(); +}); + +it('logs an error for focused state without an index after commit', () => { + const error = jest.spyOn(console, 'error').mockImplementation(() => {}); + const nodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'development'; + const reduce = jest.fn((state: NavigationState) => { + // `NavigationState` excludes incomplete committed state, which this test deliberately creates. + const incompleteState = { + ...state, + routes: [ + { + ...state.routes[0]!, + state: { + stale: false, + routeKeySeq: state.routeKeySeq, + key: state.key, + routeNames: state.routeNames, + routes: state.routes, + }, + }, + ], + } as unknown as NavigationState; + return { state: incompleteState, affectedRouteKey: state.routes[0]!.key }; + }); + const result = renderReducer({ registry: new Map([['root', entry(reduce)]]) }); + + act(() => result.result.current.handleAction({ type: 'INCOMPLETE' })); + + expect(error).toHaveBeenCalledWith('Detected stale state. This is likely a bug in Expo Router.'); + process.env.NODE_ENV = nodeEnv; + error.mockRestore(); }); it('reduces consecutive queued intents against accumulated state', () => { diff --git a/packages/expo-router/src/global-state/navigationRef.ts b/packages/expo-router/src/global-state/navigationRef.ts new file mode 100644 index 00000000000000..2072d26812d18c --- /dev/null +++ b/packages/expo-router/src/global-state/navigationRef.ts @@ -0,0 +1,4 @@ +import { createNavigationContainerRef } from '../react-navigation/core/createNavigationContainerRef'; + +// TODO(@ubax): scope this module-level mutable state to each navigation container +export const navigationRef = createNavigationContainerRef(); diff --git a/packages/expo-router/src/global-state/router-store.tsx b/packages/expo-router/src/global-state/router-store.tsx deleted file mode 100644 index 590892711d9491..00000000000000 --- a/packages/expo-router/src/global-state/router-store.tsx +++ /dev/null @@ -1,8 +0,0 @@ -// Re-export shim โ€” preserves all existing import paths. -// TODO: Refactor consumers to import directly from the new modules, then delete this file. - -export { store } from './store'; -export type { RouterStore } from './store'; -export { useStore } from './useStore'; -export { useRouteInfo } from './useRouteInfo'; -export type { StoreRedirects, ReactNavigationState, FocusedRouteState } from './types'; diff --git a/packages/expo-router/src/global-state/router.ts b/packages/expo-router/src/global-state/router.ts index 41c91de0152a41..dd508a83a2e448 100644 --- a/packages/expo-router/src/global-state/router.ts +++ b/packages/expo-router/src/global-state/router.ts @@ -13,12 +13,13 @@ import { resolveHref } from '../link/href'; import type { Href, RoutePath, RouteInputParams } from '../types'; import { getHistoryLength } from '../utils/stack'; import { shouldLinkExternally } from '../utils/url'; +import { navigationRef } from './navigationRef'; import type { RoutingIntent } from './routingQueue'; -import { store } from './store'; import type { LinkToOptions, NavigationOptions } from './types'; function assertIsReady() { - if (!store.navigationRef.isReady()) { + // TODO(@ubax): check whether this is still needed + if (!navigationRef.isReady()) { throw new 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.' ); @@ -108,10 +109,11 @@ export function canGoBack(): boolean { // before mounting a navigator. This behavior exists due to React Navigation being dynamically // constructed at runtime. We can get rid of this in the future if we use // the static configuration internally. - if (!store.navigationRef.isReady()) { + // TODO(@ubax): check whether this is still needed + if (!navigationRef.isReady()) { return false; } - return store.navigationRef?.current?.canGoBack() ?? false; + return navigationRef.current?.canGoBack() ?? false; } export function canDismiss(): boolean { @@ -120,7 +122,12 @@ export function canDismiss(): boolean { 'canDismiss imperative method is not supported. Pass the property to the DOM component instead.' ); } - let state = store.state; + // TODO(@ubax): check whether this is still needed + if (!navigationRef.isReady()) { + return false; + } + // TODO(@ubax): check whether this is still needed + let state = navigationRef.getRootState(); // Keep traversing down the state tree until we find a stack navigator that we can pop while (state) { @@ -144,7 +151,7 @@ export function setParams( return; } assertIsReady(); - return (store.navigationRef?.current?.setParams as any)(params); + return (navigationRef.current?.setParams as any)(params); } function linkToImpl( diff --git a/packages/expo-router/src/global-state/routerConfigContext.ts b/packages/expo-router/src/global-state/routerConfigContext.ts new file mode 100644 index 00000000000000..34c547db323d7e --- /dev/null +++ b/packages/expo-router/src/global-state/routerConfigContext.ts @@ -0,0 +1,15 @@ +'use client'; + +import { createContext } from 'react'; + +import type { RouteNode } from '../Route'; +import type { ExpoLinkingOptions } from '../getLinkingConfig'; +import type { StoreRedirects } from './types'; + +export type RouterConfig = { + routeNode: RouteNode | null; + linking: ExpoLinkingOptions | undefined; + redirects: StoreRedirects[]; +}; + +export const RouterConfigContext = createContext(null); diff --git a/packages/expo-router/src/global-state/sort-routes.ts b/packages/expo-router/src/global-state/sort-routes.ts deleted file mode 100644 index d02b8a4c5de84e..00000000000000 --- a/packages/expo-router/src/global-state/sort-routes.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { sortRoutes } from '../Route'; -import type { RouterStore } from './store'; - -export function getSortedRoutes(this: RouterStore) { - if (!this.routeNode) { - throw new Error('No routes found'); - } - - return this.routeNode.children.filter((route) => !route.internal).sort(sortRoutes); -} diff --git a/packages/expo-router/src/global-state/store.ts b/packages/expo-router/src/global-state/store.ts deleted file mode 100644 index 03b779dd29da53..00000000000000 --- a/packages/expo-router/src/global-state/store.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { RouteNode } from '../Route'; -import type { ExpoLinkingOptions } from '../getLinkingConfig'; -import type { NavigationContainerRefWithCurrent } from '../react-navigation/native'; -import * as SplashScreen from '../views/Splash'; -import { defaultRouteInfo, getRouteInfoFromState, type UrlObject } from './getRouteInfoFromState'; -import type { ReactNavigationState, StoreRedirects } from './types'; - -export type RouterStore = typeof store; - -type StoreRef = { - owner?: object; - navigationRef: NavigationContainerRefWithCurrent; - routeNode: RouteNode | null; - state?: ReactNavigationState; - linking?: ExpoLinkingOptions; - redirects?: StoreRedirects[]; -}; - -export const storeRef = { - current: {} as StoreRef, -}; - -export function syncStoreNavigationState(state: ReactNavigationState) { - storeRef.current.state = state; - - if (process.env.NODE_ENV === 'development') { - let isStale: boolean | undefined = false; - let focusedState: ReactNavigationState | undefined = state; - - while (!isStale && focusedState) { - isStale = focusedState.stale; - focusedState = - focusedState.routes?.[ - 'index' in focusedState && typeof focusedState.index === 'number' - ? focusedState.index - : focusedState.routes.length - 1 - ]?.state; - } - if (isStale) { - console.error('Detected stale state. This is likely a bug in Expo Router.'); - } - } -} - -let splashScreenAnimationFrame: number | undefined; -let hasAttemptedToHideSplash = false; - -export function getSplashScreenAnimationFrame() { - return splashScreenAnimationFrame; -} - -export function setSplashScreenAnimationFrame(value: number | undefined) { - splashScreenAnimationFrame = value; -} - -function setHasAttemptedToHideSplash(value: boolean) { - hasAttemptedToHideSplash = value; -} - -export function maybeHideSplashScreen() { - if (!hasAttemptedToHideSplash) { - setHasAttemptedToHideSplash(true); - setSplashScreenAnimationFrame( - requestAnimationFrame(() => { - SplashScreen._internal_maybeHideAsync?.(); - }) - ); - } -} - -let routeInfoState: ReactNavigationState | undefined; -let routeInfo = defaultRouteInfo; - -export const store = { - get state() { - return storeRef.current.state; - }, - get navigationRef() { - return storeRef.current.navigationRef; - }, - // TODO: Rename this to `rootRouteNode`; it represents the root node of the app's route tree. - get routeNode() { - return storeRef.current.routeNode; - }, - getRouteInfo(): UrlObject { - const state = storeRef.current.state; - if (state !== routeInfoState) { - routeInfoState = state; - routeInfo = state ? getRouteInfoFromState(state) : defaultRouteInfo; - } - return routeInfo; - }, - get linking() { - return storeRef.current.linking; - }, - get redirects() { - return storeRef.current.redirects || []; - }, -}; diff --git a/packages/expo-router/src/global-state/storeContext.ts b/packages/expo-router/src/global-state/storeContext.ts deleted file mode 100644 index a01716fee6ca13..00000000000000 --- a/packages/expo-router/src/global-state/storeContext.ts +++ /dev/null @@ -1,19 +0,0 @@ -'use client'; -import { createContext } from 'react'; -import type { ComponentType } from 'react'; - -import type { RouteNode } from '../Route'; -import type { ExpoLinkingOptions } from '../getLinkingConfig'; -import type { NavigationContainerRefWithCurrent } from '../react-navigation/native'; -import type { ReactNavigationState, StoreRedirects } from './types'; - -export type StoreContextValue = { - navigationRef: NavigationContainerRefWithCurrent; - linking: ExpoLinkingOptions | undefined; - state: ReactNavigationState | undefined; - rootComponent: ComponentType; - routeNode: RouteNode | null; - redirects: StoreRedirects[]; -}; - -export const StoreContext = createContext(null); diff --git a/packages/expo-router/src/global-state/useNavigationTreeReducer.ts b/packages/expo-router/src/global-state/useNavigationTreeReducer.ts index a08a9b06b2921b..eb6dd8e03017da 100644 --- a/packages/expo-router/src/global-state/useNavigationTreeReducer.ts +++ b/packages/expo-router/src/global-state/useNavigationTreeReducer.ts @@ -47,11 +47,25 @@ type Options = { registry: RouterRegistry; linking?: ExpoLinkingOptions; redirects?: StoreRedirects[]; - onStateChangeInsertion?: (state: NavigationState) => void; }; const warnedActions = new WeakSet(); +function warnIfStaleState(state: NavigationState) { + if (process.env.NODE_ENV !== 'development') { + return; + } + + let focusedState: NavigationState | undefined = state; + while (focusedState) { + if (focusedState.stale || focusedState.index === undefined) { + console.error('Detected stale state. This is likely a bug in Expo Router.'); + return; + } + focusedState = focusedState.routes[focusedState.index]?.state as NavigationState | undefined; + } +} + function warnUnhandledAction(action: NavigationAction) { if (process.env.NODE_ENV === 'production' || warnedActions.has(action)) { return; @@ -201,7 +215,6 @@ export function useNavigationTreeReducer({ registry, linking, redirects, - onStateChangeInsertion, }: Options) { const [state, reactDispatch] = React.useReducer( navigationTreeReducer, @@ -250,9 +263,8 @@ export function useNavigationTreeReducer({ }); React.useInsertionEffect(() => { - // TODO(@ubax): Check if this is still needed - onStateChangeInsertion?.(state); - }, [onStateChangeInsertion, state]); + warnIfStaleState(state); + }, [state]); useClientLayoutEffect(() => { const previousRegistry = previousRegistryRef.current; diff --git a/packages/expo-router/src/global-state/useStore.ts b/packages/expo-router/src/global-state/useStore.ts index 9395edda9ff17a..35275e6e69892f 100644 --- a/packages/expo-router/src/global-state/useStore.ts +++ b/packages/expo-router/src/global-state/useStore.ts @@ -2,31 +2,27 @@ import Constants from 'expo-constants'; import type { ComponentType } from 'react'; -import { Fragment, useEffect, useMemo, useState } from 'react'; +import { Fragment, useEffect, useMemo } from 'react'; import { Platform } from 'react-native'; -import type { RouteNode } from '../Route'; -import { extractExpoPathFromURL } from '../fork/extractPathFromURL'; import { routePatternToRegex } from '../fork/getStateFromPath-forks'; import type { ExpoLinkingOptions, LinkingConfigOptions } from '../getLinkingConfig'; import { getLinkingConfig } from '../getLinkingConfig'; import { parseRouteSegments } from '../getReactNavigationConfig'; import { getRoutes } from '../getRoutes'; -import { type NavigationState, useNavigationContainerRef } from '../react-navigation/native'; import type { RequireContext } from '../types'; import { getQualifiedRouteComponent } from '../useScreens'; +import { cancelSplashScreenAnimationFrame } from '../utils/splash'; import { shouldLinkExternally } from '../utils/url'; -import { createSeededRootState } from './createSeededNavigationState'; -import { storeRef, getSplashScreenAnimationFrame, setSplashScreenAnimationFrame } from './store'; -import type { StoreContextValue } from './storeContext'; +import type { RouterConfig } from './routerConfigContext'; import type { StoreRedirects } from './types'; -export function useStore( +// TODO(@ubax): rename this file to useRouterConfig.ts +export function useRouterConfig( context: RequireContext, linkingConfigOptions: LinkingConfigOptions, serverUrl?: string -): StoreContextValue { - const navigationRef = useNavigationContainerRef(); +): { routerConfig: RouterConfig; rootComponent: ComponentType } { const config = Constants.expoConfig?.extra?.router; const configValue = useMemo(() => { let linking: ExpoLinkingOptions | undefined; @@ -71,72 +67,12 @@ export function useStore( rootComponent = Fragment; } - return { linking, rootComponent, redirects, routeNode }; + return { routerConfig: { linking, redirects, routeNode }, rootComponent }; }, [config, context, linkingConfigOptions, serverUrl]); - const { linking, rootComponent, redirects, routeNode } = configValue; - - // One object per mount: identity marks store ownership, and state is seeded once from the URL - // (or left undefined when the URL is asynchronous). - const [owner] = useState(() => ({ state: seedInitialState(linking, routeNode) })); - const isFirstRender = storeRef.current.owner !== owner; - const state = isFirstRender ? owner.state : storeRef.current.state; - - // TODO(@ubax): move ownership to commit/teardown so concurrent roots cannot clobber this ref. - // https://linear.app/expo/issue/ENG-26124 - storeRef.current = { - owner, - navigationRef, - routeNode, - linking, - redirects, - state, - }; - - const storeValue = useMemo( - () => ({ - navigationRef, - linking, - get state() { - return storeRef.current.state; - }, - rootComponent, - redirects, - routeNode, - }), - [navigationRef, linking, rootComponent, redirects, routeNode] - ); - useEffect(() => { - return () => { - const animationFrame = getSplashScreenAnimationFrame(); - if (animationFrame) { - cancelAnimationFrame(animationFrame); - setSplashScreenAnimationFrame(undefined); - } - }; - }); - - return storeValue; -} - -function seedInitialState( - linking: ExpoLinkingOptions | undefined, - routeNode: RouteNode | null -): NavigationState | undefined { - // Static rendering only gets one pass, so synchronously available URLs are seeded immediately. - if (!linking || !routeNode) { - return undefined; - } - - const initialURL = linking.getInitialURL?.(); - if (typeof initialURL !== 'string') { - return undefined; - } - - let initialPath = extractExpoPathFromURL(linking.prefixes, initialURL); - // It does not matter if the path starts with a `/`, but this keeps parsing consistent. - if (!initialPath.startsWith('/')) initialPath = '/' + initialPath; + return cancelSplashScreenAnimationFrame; + }, []); - return createSeededRootState(linking.getStateFromPath!(initialPath, linking.config), routeNode); + return configValue; } diff --git a/packages/expo-router/src/hooks/__tests__/useRootNavigation.test.ios.tsx b/packages/expo-router/src/hooks/__tests__/useRootNavigation.test.ios.tsx new file mode 100644 index 00000000000000..df0f958894c98c --- /dev/null +++ b/packages/expo-router/src/hooks/__tests__/useRootNavigation.test.ios.tsx @@ -0,0 +1,30 @@ +import { act } from '@testing-library/react-native'; +import { useState } from 'react'; + +import { useRootNavigation } from '../useRootNavigation'; +import { renderHook } from './renderHook'; + +let error: jest.SpyInstance; + +beforeEach(() => { + error = jest.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterEach(() => { + error.mockRestore(); +}); + +it('returns the navigation container for its own router root', () => { + let rerenderFirstRoot: () => void = () => {}; + const first = renderHook(() => { + const [, setRenderCount] = useState(0); + rerenderFirstRoot = () => setRenderCount((count) => count + 1); + return useRootNavigation(); + }); + const second = renderHook(() => useRootNavigation()); + + act(rerenderFirstRoot); + + expect(first.result.current).not.toBeNull(); + expect(first.result.current).not.toBe(second.result.current); +}); diff --git a/packages/expo-router/src/hooks/useNavigationContainerRef.ts b/packages/expo-router/src/hooks/useNavigationContainerRef.ts index 166aa636d7a09c..87ad6629057d7b 100644 --- a/packages/expo-router/src/hooks/useNavigationContainerRef.ts +++ b/packages/expo-router/src/hooks/useNavigationContainerRef.ts @@ -1,11 +1,12 @@ 'use client'; -import { store } from '../global-state/store'; +import { navigationRef } from '../global-state/navigationRef'; /** * @return The root `` ref for the app. The `ref.current` may be `null` * if the `` hasn't mounted yet. */ export function useNavigationContainerRef() { - return store.navigationRef; + // TODO(@ubax): migrate this to NavigationContainerRefContext without changing the public return type + return navigationRef; } diff --git a/packages/expo-router/src/hooks/useRootNavigation.ts b/packages/expo-router/src/hooks/useRootNavigation.ts index cd95f108f84e9a..7ddb8075841e7e 100644 --- a/packages/expo-router/src/hooks/useRootNavigation.ts +++ b/packages/expo-router/src/hooks/useRootNavigation.ts @@ -1,11 +1,13 @@ 'use client'; -import { store } from '../global-state/store'; +import { use } from 'react'; + +import { NavigationContainerRefContext } from '../react-navigation/native'; /** * @deprecated Use [`useNavigationContainerRef`](#usenavigationcontainerref) instead, * which returns a React `ref`. */ export function useRootNavigation() { - return store.navigationRef.current; + return use(NavigationContainerRefContext) ?? null; } diff --git a/packages/expo-router/src/layouts/__tests__/StackClient.test.web.tsx b/packages/expo-router/src/layouts/__tests__/StackClient.test.web.tsx index d3f7876d5f50c8..4f0e12b18ed1ff 100644 --- a/packages/expo-router/src/layouts/__tests__/StackClient.test.web.tsx +++ b/packages/expo-router/src/layouts/__tests__/StackClient.test.web.tsx @@ -3,7 +3,8 @@ import { act, render, screen } from '@testing-library/react'; import { View } from 'react-native'; import { ExpoRoot } from '../../ExpoRoot'; -import { store } from '../../global-state/router-store'; +import { getRouteInfoFromState } from '../../global-state/getRouteInfoFromState'; +import { navigationRef } from '../../global-state/navigationRef'; import { router } from '../../imperative-api'; import type { NativeStackHeaderProps } from '../../react-navigation/native-stack'; import { getMockContext } from '../../testing-library/mock-config'; @@ -43,11 +44,11 @@ describe('StackClient on web', () => { act(() => router.push('/second')); expect(screen.getByTestId('second')).toBeTruthy(); - expect(store.getRouteInfo().pathname).toBe('/second'); + expect(getRouteInfoFromState(navigationRef.getRootState()).pathname).toBe('/second'); expect(backHref).toBe('/'); act(() => router.back()); expect(screen.getByTestId('index')).toBeTruthy(); - expect(store.getRouteInfo().pathname).toBe('/'); + expect(getRouteInfoFromState(navigationRef.getRootState()).pathname).toBe('/'); }); }); diff --git a/packages/expo-router/src/link/linking.ts b/packages/expo-router/src/link/linking.ts index 12241e03d2366d..7491a7c3503fdd 100644 --- a/packages/expo-router/src/link/linking.ts +++ b/packages/expo-router/src/link/linking.ts @@ -9,7 +9,7 @@ import { getInitialURLWithTimeout } from '../fork/getInitialURLWithTimeout'; import { getPathFromState } from '../fork/getPathFromState'; import { getStateFromPath } from '../fork/getStateFromPath'; import { applyRedirects } from '../getRoutesRedirects'; -import type { StoreRedirects } from '../global-state/router-store'; +import type { StoreRedirects } from '../global-state/types'; import type { LinkingOptions } from '../react-navigation/native'; import type { NativeIntent } from '../types'; diff --git a/packages/expo-router/src/link/preview/HrefPreview.tsx b/packages/expo-router/src/link/preview/HrefPreview.tsx index eacc985fe65350..816f19f665d325 100644 --- a/packages/expo-router/src/link/preview/HrefPreview.tsx +++ b/packages/expo-router/src/link/preview/HrefPreview.tsx @@ -7,7 +7,7 @@ import { findRouteNodeAndParamsForState, type RouteNode } from '../../Route'; import { INTERNAL_SLOT_NAME } from '../../constants'; import type { ResultState } from '../../exports'; import { CompositionContext } from '../../fork/native-stack/composition-options'; -import { StoreContext } from '../../global-state/storeContext'; +import { RouterConfigContext } from '../../global-state/routerConfigContext'; import type { ReactNavigationState } from '../../global-state/types'; import { useRouteInfo } from '../../global-state/useRouteInfo'; import { getRootStackRouteNames } from '../../global-state/utils'; @@ -28,7 +28,7 @@ import { PreviewRouteContext } from './PreviewRouteContext'; export function HrefPreview({ href }: { href: Href }) { // TODO(@ubax): Extract `linking` and `routeNode` into separate contexts to avoid unrelated rerenders. const { segments: routeSegments } = useRouteInfo(); - const { linking, routeNode } = use(StoreContext) ?? {}; + const { linking, routeNode } = use(RouterConfigContext) ?? {}; const rootNavigationState = use(RootNavigationStateContext); const hrefState = useMemo( () => getStateForHref(href, { segments: routeSegments }, linking), diff --git a/packages/expo-router/src/link/preview/__tests__/utils.test.ios.tsx b/packages/expo-router/src/link/preview/__tests__/utils.test.ios.tsx index f659f9d0338e28..cf9c7cb6551a0e 100644 --- a/packages/expo-router/src/link/preview/__tests__/utils.test.ios.tsx +++ b/packages/expo-router/src/link/preview/__tests__/utils.test.ios.tsx @@ -1,9 +1,12 @@ -import { store } from '../../../global-state/router-store'; +import { getLinkingConfig } from '../../../getLinkingConfig'; +import { getRoutes } from '../../../getRoutes'; +import { getRouteInfoFromState } from '../../../global-state/getRouteInfoFromState'; +import { navigationRef } from '../../../global-state/navigationRef'; import { Stack } from '../../../layouts/Stack'; import { NativeTabs } from '../../../native-tabs/index'; import { INTERNAL_EXPO_ROUTER_IS_PREVIEW_NAVIGATION_PARAM_NAME } from '../../../navigationParams'; import type { NavigationState } from '../../../react-navigation/native'; -import { renderRouter } from '../../../testing-library'; +import { getMockContext, renderRouter } from '../../../testing-library'; import { deepEqual, getPreloadedRouteFromRootStateByHref, @@ -29,6 +32,40 @@ afterAll(() => { console.info = originalConsoleInfo; }); +const routes = { + _layout: () => ( + + + + + + ), + index: () => null, + 'faces/_layout': () => , + 'faces/index': () => null, + 'faces/[face]': () => null, + 'explore/_layout': () => , + 'explore/index': () => null, + 'explore/news/_layout': () => , + 'explore/news/index': () => null, + 'explore/news/[title]': () => null, +}; +const context = getMockContext(routes); +const routeNode = getRoutes(context, { + ignoreEntryPoints: true, + platform: 'ios', + preserveRedirectAndRewrites: true, + skipGenerated: true, +})!; +const linking = getLinkingConfig(routeNode, context, { + metaOnly: false, + redirects: [], + skipGenerated: false, + sitemap: true, + notFound: true, +}); +const getRouteInfo = () => getRouteInfoFromState(navigationRef.getRootState()); + describe('deepEqual', () => { it('returns true for same object reference', () => { const obj = { a: 1 }; @@ -94,24 +131,7 @@ describe('deepEqual', () => { describe(getTabPathFromRootStateByHref, () => { beforeEach(() => { - renderRouter({ - _layout: () => ( - - - - - - ), - index: () => null, - 'faces/_layout': () => , - 'faces/index': () => null, - 'faces/[face]': () => null, - 'explore/_layout': () => , - 'explore/index': () => null, - 'explore/news/_layout': () => , - 'explore/news/index': () => null, - 'explore/news/[title]': () => null, - }); + renderRouter(routes); }); it('returns single tab path with one tab navigator in href, but without change', () => { @@ -183,8 +203,8 @@ describe(getTabPathFromRootStateByHref, () => { const tabPath = getTabPathFromRootStateByHref( href, state as NavigationState, - store.getRouteInfo(), - store.linking + getRouteInfo(), + linking ); expect(tabPath).toEqual([ { @@ -263,8 +283,8 @@ describe(getTabPathFromRootStateByHref, () => { const tabPath = getTabPathFromRootStateByHref( href, state as NavigationState, - store.getRouteInfo(), - store.linking + getRouteInfo(), + linking ); expect(tabPath).toEqual([ { @@ -279,24 +299,7 @@ describe(getPreloadedRouteFromRootStateByHref, () => { let getStateForHref: jest.SpyInstance | undefined; beforeEach(() => { - renderRouter({ - _layout: () => ( - - - - - - ), - index: () => null, - 'faces/_layout': () => , - 'faces/index': () => null, - 'faces/[face]': () => null, - 'explore/_layout': () => , - 'explore/index': () => null, - 'explore/news/_layout': () => , - 'explore/news/index': () => null, - 'explore/news/[title]': () => null, - }); + renderRouter(routes); }); afterEach(() => { @@ -374,8 +377,8 @@ describe(getPreloadedRouteFromRootStateByHref, () => { href, // The inline fixture is a complete navigation state despite widened string literals. state as NavigationState, - store.getRouteInfo(), - store.linking + getRouteInfo(), + linking ); expect(preloadedRoute).toEqual({ key: '[face]-9rms2gdsibY9dVYUGCpZG', @@ -456,8 +459,8 @@ describe(getPreloadedRouteFromRootStateByHref, () => { href, // The inline fixture is a complete navigation state despite widened string literals. state as NavigationState, - store.getRouteInfo(), - store.linking + getRouteInfo(), + linking ); expect(preloadedRoute).toEqual({ key: '[face]-MZ5nYkDCFxwNv1BcD5exf', @@ -469,7 +472,7 @@ describe(getPreloadedRouteFromRootStateByHref, () => { }); it('matches the preloaded route by nested state shape', () => { - getStateForHref = jest.spyOn(store.linking!, 'getStateFromPath').mockReturnValue({ + getStateForHref = jest.spyOn(linking, 'getStateFromPath').mockReturnValue({ routes: [ { name: 'details', @@ -558,13 +561,13 @@ describe(getPreloadedRouteFromRootStateByHref, () => { type: 'stack' as const, }; - expect( - getPreloadedRouteFromRootStateByHref('/details', state, store.getRouteInfo(), store.linking) - ).toBe(matchingRoute); + expect(getPreloadedRouteFromRootStateByHref('/details', state, getRouteInfo(), linking)).toBe( + matchingRoute + ); }); it('does not match a preloaded route from a different branch', () => { - getStateForHref = jest.spyOn(store.linking!, 'getStateFromPath').mockReturnValue({ + getStateForHref = jest.spyOn(linking, 'getStateFromPath').mockReturnValue({ routes: [ { name: 'target', @@ -602,12 +605,7 @@ describe(getPreloadedRouteFromRootStateByHref, () => { }; expect( - getPreloadedRouteFromRootStateByHref( - '/target/child', - state, - store.getRouteInfo(), - store.linking - ) + getPreloadedRouteFromRootStateByHref('/target/child', state, getRouteInfo(), linking) ).toBeUndefined(); }); }); diff --git a/packages/expo-router/src/link/preview/useNextScreenId.ts b/packages/expo-router/src/link/preview/useNextScreenId.ts index 04e1ee98bf0324..f41ae27bd79d3c 100644 --- a/packages/expo-router/src/link/preview/useNextScreenId.ts +++ b/packages/expo-router/src/link/preview/useNextScreenId.ts @@ -1,22 +1,24 @@ import { use, useCallback, useEffect, useEffectEvent, useRef, useState } from 'react'; -import type { ReactNavigationState } from '../../global-state/router-store'; -import { StoreContext } from '../../global-state/storeContext'; +import { RouterConfigContext } from '../../global-state/routerConfigContext'; +import type { ReactNavigationState } from '../../global-state/types'; import { useRouteInfo } from '../../global-state/useRouteInfo'; import { useRouter } from '../../hooks'; +import { NavigationContainerRefContext } from '../../react-navigation/native'; import type { Href } from '../../types'; import { useLinkPreviewContext } from './LinkPreviewContext'; import type { TabPath } from './native'; import { getPreloadedRouteFromRootStateByHref, getTabPathFromRootStateByHref } from './utils'; +// TODO(@ubax): Check if this can be migrated away from state listener export function useNextScreenId(): [ { nextScreenId: string | undefined; tabPath: TabPath[] }, (href: Href) => void, ] { const router = useRouter(); const routeInfo = useRouteInfo(); - const store = use(StoreContext); - const navigationRef = store?.navigationRef; + const routerConfig = use(RouterConfigContext); + const navigation = use(NavigationContainerRefContext); const { setOpenPreviewKey } = useLinkPreviewContext(); const [internalNextScreenId, internalSetNextScreenId] = useState(); const currentHref = useRef(undefined); @@ -30,14 +32,14 @@ export function useNextScreenId(): [ currentHref.current, state, routeInfo, - store?.linking + routerConfig?.linking ); const routeKey = preloadedRoute?.key; const tabPathFromRootState = getTabPathFromRootStateByHref( currentHref.current, state, routeInfo, - store?.linking + routerConfig?.linking ); // Without this timeout react-native does not have enough time to mount the new screen // and thus it will not be found on the native side @@ -57,8 +59,8 @@ export function useNextScreenId(): [ useEffect(() => { // When screen is prefetched, then the root state is updated with the preloaded route. - return navigationRef?.addListener('state', onNavigationStateChange); - }, [navigationRef]); + return navigation?.addListener('state', onNavigationStateChange); + }, [navigation]); const prefetch = useCallback( (href: Href): void => { diff --git a/packages/expo-router/src/link/preview/utils.ts b/packages/expo-router/src/link/preview/utils.ts index 4d74165e1a440b..b8367c0524fe0f 100644 --- a/packages/expo-router/src/link/preview/utils.ts +++ b/packages/expo-router/src/link/preview/utils.ts @@ -1,7 +1,7 @@ import type { ExpoLinkingOptions } from '../../getLinkingConfig'; import type { UrlObject } from '../../global-state/getRouteInfoFromState'; -import type { ReactNavigationState } from '../../global-state/router-store'; import { findDivergentState } from '../../global-state/routing'; +import type { ReactNavigationState } from '../../global-state/types'; import { removeInternalExpoRouterParams } from '../../navigationParams'; import type { ParamListBase, diff --git a/packages/expo-router/src/link/useLoadedNavigation.ts b/packages/expo-router/src/link/useLoadedNavigation.ts index a535c8b115ced2..7604cee36f1b03 100644 --- a/packages/expo-router/src/link/useLoadedNavigation.ts +++ b/packages/expo-router/src/link/useLoadedNavigation.ts @@ -1,8 +1,11 @@ -import { useCallback, useState, useEffect, useRef } from 'react'; +import { use, useCallback, useState, useEffect, useRef } from 'react'; -import { store } from '../global-state/store'; -import type { NavigationProp, NavigationState } from '../react-navigation/native'; -import { useNavigation } from '../react-navigation/native'; +import { + NavigationContainerRefContext, + type NavigationProp, + type NavigationState, + useNavigation, +} from '../react-navigation/native'; type GenericNavigation = NavigationProp & { getState(): NavigationState | undefined; @@ -11,6 +14,7 @@ type GenericNavigation = NavigationProp & { /** Returns a callback which is invoked when the navigation state has loaded. */ export function useLoadedNavigation() { const navigation = useNavigation(); + const rootNavigation = use(NavigationContainerRefContext); const isMounted = useRef(true); const pending = useRef<((navigation: GenericNavigation) => void)[]>([]); @@ -32,19 +36,19 @@ export function useLoadedNavigation() { }, [navigation]); useEffect(() => { - if (store.navigationRef.current) { + if (rootNavigation) { flush(); } - }, [flush]); + }, [flush, rootNavigation]); const push = useCallback( (fn: (navigation: GenericNavigation) => void) => { pending.current.push(fn); - if (store.navigationRef.current) { + if (rootNavigation) { flush(); } }, - [flush] + [flush, rootNavigation] ); return push; diff --git a/packages/expo-router/src/navigationEvents/navigation.ts b/packages/expo-router/src/navigationEvents/navigation.ts deleted file mode 100644 index 118499987453c7..00000000000000 --- a/packages/expo-router/src/navigationEvents/navigation.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { emit } from '.'; -import { storeRef } from '../global-state/store'; - -// TODO(@ubax): replace this singleton reader when store ownership has commit/teardown semantics. -// https://linear.app/expo/issue/ENG-26124 - -let unsubscribe: (() => void) | undefined; - -export function handleNavigationOnReady() { - if (unsubscribe) unsubscribe(); - unsubscribe = storeRef.current.navigationRef.addListener('__unsafe_action__', (e) => { - if (!e.data.noop && storeRef.current.state) { - const action = e.data.action; - emit('actionDispatched', { - actionType: action.type, - payload: action.payload, - state: storeRef.current.state, - }); - } - }); -} diff --git a/packages/expo-router/src/react-navigation/core/BaseNavigationContainer.tsx b/packages/expo-router/src/react-navigation/core/BaseNavigationContainer.tsx index 83e19ae8edcec2..fae0e9fe478849 100644 --- a/packages/expo-router/src/react-navigation/core/BaseNavigationContainer.tsx +++ b/packages/expo-router/src/react-navigation/core/BaseNavigationContainer.tsx @@ -11,8 +11,8 @@ import { getRouteInfoFromState, } from '../../global-state/getRouteInfoFromState'; import { RouteInfoContext } from '../../global-state/routeInfoContext'; +import { RouterConfigContext } from '../../global-state/routerConfigContext'; import { RouterRegistryContext, RouterRegistryProvider } from '../../global-state/routerRegistry'; -import { StoreContext } from '../../global-state/storeContext'; import { useNavigationTreeReducer } from '../../global-state/useNavigationTreeReducer'; import useLatestCallback from '../../utils/useLatestCallback'; import { @@ -47,7 +47,6 @@ type InternalNavigationContainerProps = Omit>; UNSTABLE_routeNode?: RouteNode; - UNSTABLE_onStateChangeInsertion?: (state: NavigationState) => void; }; const serializableWarnings: string[] = []; @@ -86,13 +85,12 @@ function BaseNavigationContainerInner({ onStateChange, onReady, UNSTABLE_routeNode, - UNSTABLE_onStateChangeInsertion, theme, children, }: InternalNavigationContainerProps) { const parent = use(NavigationStateContext); const inheritedRouteInfo = use(RouteInfoContext); - const store = use(StoreContext); + const routerConfig = use(RouterConfigContext); if (!parent.isDefault) { throw new Error( @@ -117,9 +115,8 @@ function BaseNavigationContainerInner({ initialState, routeNode: UNSTABLE_routeNode, registry, - linking: store?.linking, - redirects: store?.redirects, - onStateChangeInsertion: UNSTABLE_onStateChangeInsertion, + linking: routerConfig?.linking, + redirects: routerConfig?.redirects, }); const hasNotifiedInitialStateRef = React.useRef(false); diff --git a/packages/expo-router/src/react-navigation/core/__tests__/removePrevented.test.web.tsx b/packages/expo-router/src/react-navigation/core/__tests__/removePrevented.test.web.tsx index 4b4065cc2d21c6..8152d312e49a3d 100644 --- a/packages/expo-router/src/react-navigation/core/__tests__/removePrevented.test.web.tsx +++ b/packages/expo-router/src/react-navigation/core/__tests__/removePrevented.test.web.tsx @@ -4,7 +4,8 @@ import * as React from 'react'; import { View } from 'react-native'; import { ExpoRoot } from '../../../ExpoRoot'; -import { store } from '../../../global-state/router-store'; +import { getRouteInfoFromState } from '../../../global-state/getRouteInfoFromState'; +import { navigationRef } from '../../../global-state/navigationRef'; import { router } from '../../../imperative-api'; import Stack from '../../../layouts/StackClient'; import { getMockContext } from '../../../testing-library/mock-config'; @@ -44,12 +45,12 @@ test.skip('continues a blocked router back after disabling prevention', () => { act(() => router.back()); expect(screen.getByTestId('form')).toBeTruthy(); - expect(store.getRouteInfo().pathname).toBe('/form'); + expect(getRouteInfoFromState(navigationRef.getRootState()).pathname).toBe('/form'); expect(onPreventRemove).toHaveBeenCalledTimes(1); act(() => discard()); - expect(store.getRouteInfo().pathname).toBe('/'); + expect(getRouteInfoFromState(navigationRef.getRootState()).pathname).toBe('/'); expect(onPreventRemove).toHaveBeenCalledTimes(1); }); @@ -78,11 +79,11 @@ test.skip('continues a blocked parent back after disabling nested prevention', ( act(() => router.push('/nested')); act(() => router.back()); - expect(store.getRouteInfo().pathname).toBe('/nested'); + expect(getRouteInfoFromState(navigationRef.getRootState()).pathname).toBe('/nested'); expect(onPreventRemove).toHaveBeenCalledTimes(1); act(() => discard()); - expect(store.getRouteInfo().pathname).toBe('/'); + expect(getRouteInfoFromState(navigationRef.getRootState()).pathname).toBe('/'); expect(onPreventRemove).toHaveBeenCalledTimes(1); }); @@ -116,5 +117,5 @@ test.skip('throws a descriptive error when beforeRemove calls preventDefault', ( expect(() => act(() => goBack())).toThrow( '`beforeRemove` is a notification-only event and cannot prevent screen removal. Use `usePreventRemove` with the `removePrevented` event instead.' ); - expect(store.getRouteInfo().pathname).toBe('/form'); + expect(getRouteInfoFromState(navigationRef.getRootState()).pathname).toBe('/form'); }); diff --git a/packages/expo-router/src/testing-library/index.tsx b/packages/expo-router/src/testing-library/index.tsx index 4ef925ee58f595..97207ff91f88f7 100644 --- a/packages/expo-router/src/testing-library/index.tsx +++ b/packages/expo-router/src/testing-library/index.tsx @@ -4,10 +4,9 @@ import type { RenderResult } from '@testing-library/react-native'; import { ExpoRoot } from '../ExpoRoot'; import type { ExpoLinkingOptions } from '../getLinkingConfig'; -import type { ReactNavigationState } from '../global-state/router-store'; -import { store } from '../global-state/router-store'; -// TODO(@ubax): replace this singleton reader when store ownership has commit/teardown semantics. -// https://linear.app/expo/issue/ENG-26124 +import { getRouteInfoFromState } from '../global-state/getRouteInfoFromState'; +import { navigationRef } from '../global-state/navigationRef'; +import type { ReactNavigationState } from '../global-state/types'; import { router } from '../imperative-api'; import { type MockContextConfig, getMockContext } from './mock-config'; @@ -106,19 +105,19 @@ export function renderRouter( */ return Object.assign(result, { getPathname(this: RenderResult): string { - return store.getRouteInfo().pathname; + return getRouteInfoFromState(navigationRef.getRootState()).pathname; }, getSegments(this: RenderResult): string[] { - return store.getRouteInfo().segments; + return getRouteInfoFromState(navigationRef.getRootState()).segments; }, getSearchParams(this: RenderResult): Record { - return store.getRouteInfo().params; + return getRouteInfoFromState(navigationRef.getRootState()).params; }, getPathnameWithParams(this: RenderResult): string { - return store.getRouteInfo().pathnameWithParams; + return getRouteInfoFromState(navigationRef.getRootState()).pathnameWithParams; }, getRouterState(this: RenderResult) { - return store.state; + return navigationRef.getRootState(); }, }); } diff --git a/packages/expo-router/src/utils/splash.ts b/packages/expo-router/src/utils/splash.ts index d14d9c3b8195c1..d441c3d9093969 100644 --- a/packages/expo-router/src/utils/splash.ts +++ b/packages/expo-router/src/utils/splash.ts @@ -3,6 +3,24 @@ import { requireOptionalNativeModule } from 'expo'; const SplashModule = requireOptionalNativeModule('ExpoSplashScreen'); let _initializedErrorHandler = false; +let splashScreenAnimationFrame: number | undefined; +let hasAttemptedToHideSplash = false; + +export function maybeHideSplashScreen() { + if (!hasAttemptedToHideSplash) { + hasAttemptedToHideSplash = true; + splashScreenAnimationFrame = requestAnimationFrame(() => { + _internal_maybeHideAsync(); + }); + } +} + +export function cancelSplashScreenAnimationFrame() { + if (splashScreenAnimationFrame !== undefined) { + cancelAnimationFrame(splashScreenAnimationFrame); + splashScreenAnimationFrame = undefined; + } +} export function hide() { if (!SplashModule) { diff --git a/packages/expo-router/src/views/useSitemap.tsx b/packages/expo-router/src/views/useSitemap.tsx index 82b3f3f458c775..91b9d2d55a018b 100644 --- a/packages/expo-router/src/views/useSitemap.tsx +++ b/packages/expo-router/src/views/useSitemap.tsx @@ -2,7 +2,7 @@ import { use, useMemo } from 'react'; import type { RouteNode } from '../Route'; import { sortRoutes } from '../Route'; -import { StoreContext } from '../global-state/storeContext'; +import { RouterConfigContext } from '../global-state/routerConfigContext'; import { matchDynamicName } from '../matchers'; import type { Href } from '../types'; @@ -63,7 +63,7 @@ const mapForRoute: (route: RouteNode, parents: string[]) => SitemapType = (route export function useSitemap(): SitemapType | null { // TODO(@ubax): Extract `routeNode` into a separate context to avoid unrelated rerenders. - const routeNode = use(StoreContext)?.routeNode; + const routeNode = use(RouterConfigContext)?.routeNode; const sitemap = useMemo(() => (routeNode ? mapForRoute(routeNode, []) : null), [routeNode]); return sitemap; } From b14abcc144dad8c64e9fbd3ced20df3b0e274d88 Mon Sep 17 00:00:00 2001 From: Jakub Tkacz <32908614+Ubax@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:44:28 +0200 Subject: [PATCH 8/8] [router] fix onActionDispatched emitter and preventRemove (#49408) # Why When refactoring router to use global state - https://github.com/expo/expo/pull/49297 - a regression was introduced - prevent remove and action listeners stopped working. This PR fixes that. # How 1. When reducing in global state reducer return not only state but also events - `{ state, events }`. Events can be of different types (three right now - `action-dispatched`, `route-removed`, `remove-prevented`). 2. After next render (in `useLayoutEffect`) `useNavigationTreeReportEvents` processes these events and emits the react-navigation ones. After processing it dispatches `REPORT_CONSUMED` action to reducer to remove the processed events (by id) 3. `usePreventRemove` is refactored to align better with current architecture. It uses context to pass a set of prevented ids to the reducer. This context stores id of the route with `usePreventRemove(true)` and all of its parents. Since all the synchronization between the context passed to reducer and `usePreventRemove` is executed in effect phase, the hook returns a function which disables prevention synchronously - for the next reducer render, rather then waiting one commit. # Test Plan CI # Checklist - [ ] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [ ] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). - [ ] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) --- packages/expo-router/CHANGELOG.md | 1 + packages/expo-router/src/ExpoRoot.tsx | 36 +- .../__tests__/experimental-stack.test.ios.tsx | 3 +- .../src/__tests__/headless-tabs.test.ios.tsx | 34 +- .../src/__tests__/prefetch.test.ios.tsx | 1 - .../src/global-state/RoutingQueueDrainer.tsx | 6 +- .../RoutingQueueDrainer.test.ios.tsx | 10 +- .../__tests__/__fixtures__/routerEntry.ts | 2 - .../__tests__/removalPrevention.test.ios.tsx | 98 +++++ .../useNavigationTreeReducer.test.ios.tsx | 278 +++++++++++- ...useNavigationTreeReportEvents.test.ios.tsx | 134 ++++++ .../src/global-state/removalPrevention.tsx | 190 +++++++++ .../src/global-state/routerRegistry.tsx | 10 - .../src/global-state/routingQueue.ts | 9 - .../global-state/useNavigationTreeReducer.ts | 220 ++++++++-- .../useNavigationTreeReportEvents.ts | 69 +++ .../ExperimentalStackView.tsx | 9 +- .../core/BaseNavigationContainer.tsx | 54 ++- .../core/NavigationBuilderContext.tsx | 18 - .../core/PreventRemoveContext.tsx | 16 - .../src/react-navigation/core/SceneView.tsx | 52 ++- .../__tests__/actionBubbling.test.ios.tsx | 26 +- .../__tests__/removePrevented.test.ios.tsx | 89 ++-- .../__tests__/removePrevented.test.web.tsx | 47 +-- .../__tests__/useEventEmitter.test.ios.tsx | 16 +- .../__tests__/useNavigationCache.test.ios.tsx | 7 +- .../__tests__/usePreventRemove.test.ios.tsx | 396 +++++++----------- .../src/react-navigation/core/index.tsx | 2 - .../src/react-navigation/core/types.tsx | 35 +- .../react-navigation/core/useDescriptors.tsx | 35 +- .../react-navigation/core/useEventEmitter.tsx | 19 +- .../core/useKeyedChildListeners.tsx | 36 -- .../core/useNavigationBuilder.tsx | 73 +--- .../core/useNavigationHelpers.tsx | 8 +- .../core/useOnPreventRemove.tsx | 159 ------- .../core/usePreventRemove.tsx | 74 +++- .../core/usePreventRemoveContext.tsx | 16 - .../core/usePreventRemoveState.tsx | 117 ------ .../utils/useInvalidPreventRemoveError.tsx | 17 +- .../views/NativeStackView.native.tsx | 13 +- 40 files changed, 1394 insertions(+), 1041 deletions(-) create mode 100644 packages/expo-router/src/global-state/__tests__/removalPrevention.test.ios.tsx create mode 100644 packages/expo-router/src/global-state/__tests__/useNavigationTreeReportEvents.test.ios.tsx create mode 100644 packages/expo-router/src/global-state/removalPrevention.tsx create mode 100644 packages/expo-router/src/global-state/useNavigationTreeReportEvents.ts delete mode 100644 packages/expo-router/src/react-navigation/core/PreventRemoveContext.tsx delete mode 100644 packages/expo-router/src/react-navigation/core/useKeyedChildListeners.tsx delete mode 100644 packages/expo-router/src/react-navigation/core/useOnPreventRemove.tsx delete mode 100644 packages/expo-router/src/react-navigation/core/usePreventRemoveContext.tsx delete mode 100644 packages/expo-router/src/react-navigation/core/usePreventRemoveState.tsx diff --git a/packages/expo-router/CHANGELOG.md b/packages/expo-router/CHANGELOG.md index 50bb3808cd2206..425a707151a338 100644 --- a/packages/expo-router/CHANGELOG.md +++ b/packages/expo-router/CHANGELOG.md @@ -4,6 +4,7 @@ ### ๐Ÿ›  Breaking changes +- Remove `beforeRemove`, `__unsafe_action__`, `PreventRemoveContext`, and `usePreventRemoveContext` from `expo-router/react-navigation`. ([#49408](https://github.com/expo/expo/pull/49408) by [@Ubax](https://github.com/Ubax)) - Preserve the focused route when switching navigator types in a conditional layout. ([#49297](https://github.com/expo/expo/pull/49297) by [@Ubax](https://github.com/Ubax)) - Generate deterministic navigation states and route keys. Complete states from custom routers or persisted state must include `routeKeySeq`. ([#49297](https://github.com/expo/expo/pull/49297) by [@Ubax](https://github.com/Ubax)) - Defer `navigation.dispatch` and navigation helper actions until after commit. Use `navigation.dispatchSync` for synchronous dispatch; dispatch functions are no longer supported. ([#49297](https://github.com/expo/expo/pull/49297) by [@Ubax](https://github.com/Ubax)) diff --git a/packages/expo-router/src/ExpoRoot.tsx b/packages/expo-router/src/ExpoRoot.tsx index bee677176a94fa..ad779775592807 100644 --- a/packages/expo-router/src/ExpoRoot.tsx +++ b/packages/expo-router/src/ExpoRoot.tsx @@ -9,15 +9,14 @@ import { useDomComponentNavigation } from './domComponents/useDomComponentNaviga import { NavigationContainer as UpstreamNavigationContainer } from './fork/NavigationContainer'; import type { ExpoLinkingOptions } from './getLinkingConfig'; import { navigationRef } from './global-state/navigationRef'; +import { RemovalPreventionProvider } from './global-state/removalPrevention'; import { RouterConfigContext } from './global-state/routerConfigContext'; import { RouterRegistryProvider } from './global-state/routerRegistry'; import { RoutingQueueProvider } from './global-state/routingQueueContext'; import { useRouterConfig } from './global-state/useStore'; import { shouldAppendNotFound, shouldAppendSitemap } from './global-state/utils'; import { LinkPreviewContextProvider } from './link/preview/LinkPreviewContext'; -import { emit } from './navigationEvents'; import { Screen } from './primitives'; -import { useClientLayoutEffect } from './react-navigation/core/useClientLayoutEffect'; import type { LinkingOptions } from './react-navigation/native'; import { StackRouter, useNavigationBuilder } from './react-navigation/native'; import { initScreensFeatureFlags } from './screensFeatureFlags'; @@ -126,19 +125,6 @@ function ContextNavigator({ const { routerConfig, rootComponent } = useRouterConfig(context, linking, serverUrl); const { linking: linkingConfig, routeNode } = routerConfig; - useClientLayoutEffect(() => { - return navigationRef.addListener('__unsafe_action__', (event) => { - const state = navigationRef.getRootState(); - if (!event.data.noop && state) { - emit('actionDispatched', { - actionType: event.data.action.type, - payload: event.data.action.payload, - state, - }); - } - }); - }, []); - useDomComponentNavigation(); // TODO(@ubax): Revisit onboarding once route creation is React-owned. @@ -160,15 +146,17 @@ function ContextNavigator({ return ( - } - documentTitle={documentTitle} - onReady={onNavigationReady}> - - - - + + } + documentTitle={documentTitle} + onReady={onNavigationReady}> + + + + + ); diff --git a/packages/expo-router/src/__tests__/experimental-stack.test.ios.tsx b/packages/expo-router/src/__tests__/experimental-stack.test.ios.tsx index d7cb362d7e1839..6b00401f5d6d8d 100644 --- a/packages/expo-router/src/__tests__/experimental-stack.test.ios.tsx +++ b/packages/expo-router/src/__tests__/experimental-stack.test.ios.tsx @@ -336,8 +336,7 @@ describe('ExperimentalStack โ€” dismiss handlers', () => { } }); - // TODO(@ubax): Restore nested remove prevention after reducer dispatch supports it. https://linear.app/expo/issue/ENG-26123 - it.skip('onNativeDismissPrevented dispatches a pop through nested prevention', () => { + it('onNativeDismissPrevented dispatches a pop through nested prevention', () => { const onPreventRemove = jest.fn(); const onGestureCancel = jest.fn(); const ProtectedScreen = () => { diff --git a/packages/expo-router/src/__tests__/headless-tabs.test.ios.tsx b/packages/expo-router/src/__tests__/headless-tabs.test.ios.tsx index cb3e5aec44384a..ad8a241a30c2ce 100644 --- a/packages/expo-router/src/__tests__/headless-tabs.test.ios.tsx +++ b/packages/expo-router/src/__tests__/headless-tabs.test.ios.tsx @@ -4,13 +4,13 @@ import React, { forwardRef, useEffect, useState } from 'react'; import type { ViewProps } from 'react-native'; import { View, Text, Button } from 'react-native'; -import { navigationRef } from '../global-state/navigationRef'; import { useLocalSearchParams } from '../hooks'; import { router } from '../imperative-api'; import { useGuardRedirect } from '../layouts/GuardContext'; import { Stack } from '../layouts/Stack'; import { Tabs as JSTabs } from '../layouts/Tabs'; import { Link, Redirect } from '../link/Link'; +import { unstable_navigationEvents } from '../navigationEvents'; import { useIsFocused } from '../react-navigation/native'; import { type RenderRouterOptions, renderRouter, waitFor } from '../testing-library'; import { TabList, TabSlot, TabTrigger, Tabs, useTabTrigger } from '../ui'; @@ -1396,10 +1396,8 @@ it('resets when focused tab is pressed again', async () => { expect(screen).toHaveSegments(['stack']); }); -// TODO(@ubax): Restore __unsafe_action__ events. https://linear.app/expo/issue/ENG-26123 -it.skip('dispatches only one action when re-tapping active tab with nested stack', async () => { - // Track all dispatched actions using a listener on the navigation container - const dispatchedActions: unknown[] = []; +it('dispatches only one action when re-tapping active tab with nested stack', async () => { + const dispatchedActions: string[] = []; renderRouter({ _layout: () => ( @@ -1439,9 +1437,9 @@ it.skip('dispatches only one action when re-tapping active tab with nested stack expect(screen.getByTestId('movies-nested-details')).toBeVisible(); // Set up listener to track dispatched actions before re-tapping - const unsubscribe = navigationRef.current!.addListener('__unsafe_action__', (e) => { - dispatchedActions.push(e.data.action); - }); + const unsubscribe = unstable_navigationEvents.addListener('actionDispatched', (event) => + dispatchedActions.push(event.actionType) + ); // Re-tap the movies tab await userEvent.press(screen.getByTestId('goto-movies')); @@ -1453,15 +1451,11 @@ it.skip('dispatches only one action when re-tapping active tab with nested stack expect(dispatchedActions).toHaveLength(1); - expect(dispatchedActions[0]).toMatchObject({ - type: 'POP_TO_TOP', - }); + expect(dispatchedActions[0]).toBe('POP_TO_TOP'); }); -// TODO(@ubax): Restore __unsafe_action__ events. https://linear.app/expo/issue/ENG-26123 -it.skip('JSTabs dispatches only one action when re-tapping active tab with nested stack', async () => { - // Track all dispatched actions using a listener on the navigation container - const dispatchedActions: unknown[] = []; +it('JSTabs dispatches only one action when re-tapping active tab with nested stack', async () => { + const dispatchedActions: string[] = []; renderRouter({ _layout: () => ( @@ -1494,9 +1488,9 @@ it.skip('JSTabs dispatches only one action when re-tapping active tab with neste expect(screen.getByTestId('movies-nested-details')).toBeVisible(); // Set up listener to track dispatched actions before re-tapping - const unsubscribe = navigationRef.current!.addListener('__unsafe_action__', (e) => { - dispatchedActions.push(e.data.action); - }); + const unsubscribe = unstable_navigationEvents.addListener('actionDispatched', (event) => + dispatchedActions.push(event.actionType) + ); // Re-tap the movies tab await userEvent.press(screen.getByLabelText('movies, tab, 2 of 2')); @@ -1508,9 +1502,7 @@ it.skip('JSTabs dispatches only one action when re-tapping active tab with neste expect(dispatchedActions).toHaveLength(1); - expect(dispatchedActions[0]).toMatchObject({ - type: 'POP_TO_TOP', - }); + expect(dispatchedActions[0]).toBe('POP_TO_TOP'); }); it('does not reset when focused tab is pressed again, but the press is prevented', async () => { diff --git a/packages/expo-router/src/__tests__/prefetch.test.ios.tsx b/packages/expo-router/src/__tests__/prefetch.test.ios.tsx index cf9683617433e5..0f24e5f60efe8b 100644 --- a/packages/expo-router/src/__tests__/prefetch.test.ios.tsx +++ b/packages/expo-router/src/__tests__/prefetch.test.ios.tsx @@ -961,7 +961,6 @@ it('can still use while prefetching in tabs', () => { 'Should only change after focus', 'index', 'Should only change after focus', - 'index', ]); }); diff --git a/packages/expo-router/src/global-state/RoutingQueueDrainer.tsx b/packages/expo-router/src/global-state/RoutingQueueDrainer.tsx index 1540f984b2bf3f..8191adcfcd411d 100644 --- a/packages/expo-router/src/global-state/RoutingQueueDrainer.tsx +++ b/packages/expo-router/src/global-state/RoutingQueueDrainer.tsx @@ -27,11 +27,7 @@ export function RoutingQueueDrainer({ ready, processIntent }: Props) { // during the next render, so errors from it surface there, not here. try { intent.onDispatch?.(intent.metadata); - if (intent.type === 'NAVIGATOR_ACTION') { - intent.payload.dispatchSync(intent.payload.action); - } else { - processIntent(intent); - } + processIntent(intent); } catch (error) { const message = typeof error === 'object' && error != null && 'message' in error ? error.message : error; diff --git a/packages/expo-router/src/global-state/__tests__/RoutingQueueDrainer.test.ios.tsx b/packages/expo-router/src/global-state/__tests__/RoutingQueueDrainer.test.ios.tsx index 2159ef37590620..50037c2e079010 100644 --- a/packages/expo-router/src/global-state/__tests__/RoutingQueueDrainer.test.ios.tsx +++ b/packages/expo-router/src/global-state/__tests__/RoutingQueueDrainer.test.ios.tsx @@ -89,22 +89,16 @@ it('keeps intents queued until ready', () => { it('processes a queued batch in FIFO order', () => { const calls: string[] = []; const processIntent = jest.fn((intent: RoutingIntent) => calls.push(actionType(intent))); - const dispatchSync = jest.fn(() => calls.push('NAVIGATOR_ACTION')); const onDispatch = jest.fn(() => calls.push('onDispatch')); const result = renderDrainer(true, processIntent); act(() => { result.enqueue(actionIntent('FIRST')); - result.enqueue({ - type: 'NAVIGATOR_ACTION', - payload: { action: { type: 'SECOND' }, dispatchSync }, - onDispatch, - }); + result.enqueue({ ...actionIntent('SECOND'), onDispatch }); result.enqueue(actionIntent('THIRD')); }); - expect(calls).toEqual(['FIRST', 'onDispatch', 'NAVIGATOR_ACTION', 'THIRD']); - expect(dispatchSync).toHaveBeenCalledWith({ type: 'SECOND' }); + expect(calls).toEqual(['FIRST', 'onDispatch', 'SECOND', 'THIRD']); }); it('does not process a batch twice in Strict Mode', () => { diff --git a/packages/expo-router/src/global-state/__tests__/__fixtures__/routerEntry.ts b/packages/expo-router/src/global-state/__tests__/__fixtures__/routerEntry.ts index c9f6153073f927..e9e2a0c16e5b0f 100644 --- a/packages/expo-router/src/global-state/__tests__/__fixtures__/routerEntry.ts +++ b/packages/expo-router/src/global-state/__tests__/__fixtures__/routerEntry.ts @@ -28,7 +28,5 @@ export function entry false, getStateForRouteFocus: (state) => state, - shouldPreventRemove: () => false, - emitBeforeRemove: () => {}, }; } diff --git a/packages/expo-router/src/global-state/__tests__/removalPrevention.test.ios.tsx b/packages/expo-router/src/global-state/__tests__/removalPrevention.test.ios.tsx new file mode 100644 index 00000000000000..30f5873a42870a --- /dev/null +++ b/packages/expo-router/src/global-state/__tests__/removalPrevention.test.ios.tsx @@ -0,0 +1,98 @@ +import { act, render } from '@testing-library/react-native'; +import * as React from 'react'; +import { use } from 'react'; + +import { + GlobalRoutesWithRemovalPreventedContext, + GlobalRemovalEventEmitterRegistryContext, + isRouteRemovalPrevented, + PreventRemovalProvider, + RemovalPreventionProvider, + ScreenRemovalPreventionSetterContext, +} from '../removalPrevention'; + +test('aggregates prevention across routes', () => { + const setters = new Map void>(); + const routes: ReadonlySet[] = []; + function Capture({ routeKey }: { routeKey: string }) { + setters.set(routeKey, use(ScreenRemovalPreventionSetterContext)!); + return null; + } + function RoutesCapture() { + routes.push(use(GlobalRoutesWithRemovalPreventedContext)!); + return null; + } + render( + + + + + + + + + + ); + + act(() => { + setters.get('a')!('first', true); + setters.get('b')!('first', true); + }); + expect(routes.at(-1)).toEqual(new Set(['a', 'b'])); + + act(() => setters.get('a')!('first', false)); + expect(routes.at(-1)).toEqual(new Set(['b'])); + + act(() => setters.get('b')!('first', false)); + expect(routes.at(-1)).toEqual(new Set()); +}); + +test('detects prevention in an active descendant but not a preloaded route', () => { + const route = { + key: 'parent', + name: 'parent', + state: { + stale: false as const, + type: 'stack', + key: 'stack', + routeKeySeq: 0, + index: 0, + routeNames: ['active', 'preloaded'], + routes: [ + { key: 'active', name: 'active' }, + { key: 'preloaded', name: 'preloaded' }, + ], + }, + }; + + expect(isRouteRemovalPrevented(route, new Set(['active']))).toBe(true); + expect(isRouteRemovalPrevented(route, new Set(['preloaded']))).toBe(false); + expect(isRouteRemovalPrevented(route, new Set(['parent']))).toBe(true); +}); + +test('keeps a route emitter until the end of the task after its provider unmounts', async () => { + const action = { type: 'POP' }; + const emitRemovalEvent = jest.fn(); + let registry = null as React.ContextType; + function CaptureRegistry() { + registry = use(GlobalRemovalEventEmitterRegistryContext); + return null; + } + function Tree({ mounted }: { mounted: boolean }) { + return ( + + + {mounted && } + + ); + } + const result = render(); + + result.rerender(); + registry!.emitRemovalEvent('x', 'removed', action); + expect(emitRemovalEvent).toHaveBeenCalledWith('x', 'removed', action); + + await act(() => Promise.resolve()); + registry!.emitRemovalEvent('x', 'removed', action); + expect(emitRemovalEvent).toHaveBeenCalledTimes(1); +}); diff --git a/packages/expo-router/src/global-state/__tests__/useNavigationTreeReducer.test.ios.tsx b/packages/expo-router/src/global-state/__tests__/useNavigationTreeReducer.test.ios.tsx index 1d81255107834a..ee53822838a57a 100644 --- a/packages/expo-router/src/global-state/__tests__/useNavigationTreeReducer.test.ios.tsx +++ b/packages/expo-router/src/global-state/__tests__/useNavigationTreeReducer.test.ios.tsx @@ -28,22 +28,177 @@ const initialState: NavigationState = { }; function renderReducer({ - registry, state = initialState, + registry, + routesWithRemovalPrevented = new Set(), }: { - registry: RouterRegistry; state?: NavigationState; + registry: RouterRegistry; + routesWithRemovalPrevented?: ReadonlySet; }) { - return renderHook, { registry: RouterRegistry }>( - ({ registry }) => - useNavigationTreeReducer({ + const reports: NonNullable['report']>[] = []; + const result = renderHook< + ReturnType, + { + registry: RouterRegistry; + routesWithRemovalPrevented: ReadonlySet; + } + >( + ({ registry, routesWithRemovalPrevented }) => { + const reducer = useNavigationTreeReducer({ initialState: state, registry, - }), - { initialProps: { registry } } + routesWithRemovalPrevented, + }); + if (reducer.report) { + reports.push(reducer.report); + } + return reducer; + }, + { initialProps: { registry, routesWithRemovalPrevented } } ); + return { ...result, reports }; } +test('reports and vetoes removal of a prevented route', () => { + const action = { type: 'REMOVE' }; + const result = renderReducer({ + registry: new Map([ + [ + 'root', + entry((state) => ({ + state: { ...state, index: 0, routes: state.routes.slice(0, 1) }, + affectedRouteKey: state.routes[0]!.key, + })), + ], + ]), + routesWithRemovalPrevented: new Set(['third']), + }); + + act(() => result.result.current.handleAction(action)); + + expect(result.result.current.state).toBe(initialState); + expect(result.reports.at(-1)).toMatchObject({ + events: [{ type: 'prevented-routes', routeKeys: ['third'], action }], + }); + expect(result.result.current.report).toMatchObject({ + events: [{ type: 'prevented-routes', routeKeys: ['third'], action }], + }); +}); + +test('commits removal and reports removed routes when none are prevented', () => { + const action = { type: 'REMOVE' }; + const result = renderReducer({ + registry: new Map([ + [ + 'root', + entry((state) => ({ + state: { ...state, index: 0, routes: state.routes.slice(0, 1) }, + affectedRouteKey: state.routes[0]!.key, + })), + ], + ]), + }); + + act(() => result.result.current.handleAction(action)); + + expect(result.result.current.state.routes).toHaveLength(1); + expect(result.reports.at(-1)).toMatchObject({ + events: [ + { type: 'removed-routes', routeKeys: ['third', 'second'], action }, + { type: 'action-dispatched', action }, + ], + }); +}); + +test('does not let a preloaded stack route prevent removal', () => { + const stackState: NavigationState = { + ...initialState, + type: 'stack', + index: 0, + }; + const result = renderReducer({ + state: stackState, + registry: new Map([ + [ + 'root', + entry((state) => ({ + state: { ...state, routes: state.routes.slice(0, 1) }, + affectedRouteKey: state.routes[0]!.key, + })), + ], + ]), + routesWithRemovalPrevented: new Set(['second']), + }); + + act(() => result.result.current.handleAction({ type: 'REMOVE_PRELOAD' })); + + expect(result.result.current.state.routes).toHaveLength(1); + expect(result.reports.at(-1)?.events).toEqual([ + expect.objectContaining({ + type: 'action-dispatched', + action: { type: 'REMOVE_PRELOAD' }, + }), + ]); +}); + +test('prevents moving an active route into the preloaded region', () => { + const stackState: NavigationState = { + ...initialState, + type: 'stack', + index: 2, + }; + const result = renderReducer({ + state: stackState, + registry: new Map([ + [ + 'root', + entry((state) => ({ + state: { ...state, index: 0 }, + affectedRouteKey: state.routes[0]!.key, + })), + ], + ]), + routesWithRemovalPrevented: new Set(['second']), + }); + + act(() => result.result.current.handleAction({ type: 'RESET_INDEX' })); + + expect(result.result.current.state).toBe(stackState); + expect(result.reports.at(-1)?.events).toEqual([ + { + id: 0, + type: 'prevented-routes', + routeKeys: ['second'], + action: { type: 'RESET_INDEX' }, + }, + ]); +}); + +test('does not veto route name changes', () => { + const action = { type: 'ROUTE_NAMES_CHANGED' }; + const result = renderReducer({ + registry: new Map([ + [ + 'root', + entry((state) => ({ + state: { ...state, index: 0, routes: state.routes.slice(0, 1) }, + affectedRouteKey: state.routes[0]!.key, + })), + ], + ]), + routesWithRemovalPrevented: new Set(['third']), + }); + + act(() => result.result.current.handleAction(action)); + + expect(result.result.current.state.routes).toHaveLength(1); + expect(result.reports.at(-1)?.events).toEqual([ + { id: 0, type: 'removed-routes', routeKeys: ['third', 'second'], action }, + expect.objectContaining({ id: 1, type: 'action-dispatched', action }), + ]); +}); + it('reduces consecutive actions against accumulated state with one committed update', () => { const reduce = jest.fn((state: NavigationState) => ({ state: { ...state, index: state.index + 1 }, @@ -53,14 +208,76 @@ it('reduces consecutive actions against accumulated state with one committed upd registry: new Map([['root', entry(reduce)]]), }); + const firstAction = { type: 'NEXT_FIRST' }; + const secondAction = { type: 'NEXT_SECOND' }; act(() => { - result.result.current.handleAction({ type: 'NEXT' }); - result.result.current.handleAction({ type: 'NEXT' }); + result.result.current.handleAction(firstAction); + result.result.current.handleAction(secondAction); }); expect(reduce).toHaveBeenCalledTimes(2); expect(reduce.mock.calls[1]![0].index).toBe(1); expect(result.result.current.state.index).toBe(2); + expect(result.reports.at(-1)?.events).toEqual([ + expect.objectContaining({ + id: 0, + type: 'action-dispatched', + action: firstAction, + }), + expect.objectContaining({ + id: 1, + type: 'action-dispatched', + action: secondAction, + }), + ]); +}); + +it('assigns increasing ids to events across actions', () => { + const result = renderReducer({ + registry: new Map([ + [ + 'root', + entry((state) => ({ + state: { ...state, index: state.index + 1 }, + affectedRouteKey: state.routes[state.index + 1]!.key, + })), + ], + ]), + }); + + act(() => result.result.current.handleAction({ type: 'FIRST' })); + act(() => result.result.current.handleAction({ type: 'SECOND' })); + + expect(result.result.current.report?.events.map((event) => event.id)).toEqual([0, 1]); +}); + +it('consumes only the listed report events', () => { + const result = renderReducer({ + registry: new Map([ + [ + 'root', + entry((state) => ({ + state: { ...state, index: state.index + 1 }, + affectedRouteKey: state.routes[state.index + 1]!.key, + })), + ], + ]), + }); + + act(() => { + result.result.current.handleAction({ type: 'FIRST' }); + result.result.current.handleAction({ type: 'SECOND' }); + }); + act(() => result.result.current.consumeReportEvents([0])); + + expect(result.result.current.report?.events.map((event) => event.id)).toEqual([1]); + + const report = result.result.current.report; + act(() => result.result.current.consumeReportEvents([99])); + expect(result.result.current.report).toBe(report); + + act(() => result.result.current.consumeReportEvents([1])); + expect(result.result.current.report).toBeUndefined(); }); it('logs an error for stale focused state after commit', () => { @@ -75,7 +292,9 @@ it('logs an error for stale focused state after commit', () => { } as unknown as NavigationState; return { state: staleState, affectedRouteKey: state.routes[0]!.key }; }); - const result = renderReducer({ registry: new Map([['root', entry(reduce)]]) }); + const result = renderReducer({ + registry: new Map([['root', entry(reduce)]]), + }); act(() => result.result.current.handleAction({ type: 'STALE' })); @@ -145,7 +364,13 @@ it('warns for direct navigation actions carrying a screen param', () => { const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); const result = renderReducer({ registry: new Map([ - ['root', entry((state) => ({ state, affectedRouteKey: state.routes[state.index]!.key }))], + [ + 'root', + entry((state) => ({ + state, + affectedRouteKey: state.routes[state.index]!.key, + })), + ], ]), }); @@ -199,9 +424,14 @@ it('resets a state slice when its router unregisters', () => { const routeNode = node('root', [node('first'), node('second'), node('third')]); routeNode.initialRouteName = 'second'; const registryEntry = { ...entry(() => null), routeNode }; - const result = renderReducer({ registry: new Map([['root', registryEntry]]) }); + const result = renderReducer({ + registry: new Map([['root', registryEntry]]), + }); - result.rerender({ registry: new Map() }); + result.rerender({ + registry: new Map(), + routesWithRemovalPrevented: new Set(), + }); expect(result.result.current.state).toMatchObject({ index: 0, @@ -239,7 +469,10 @@ it('does not reset a state slice when its router entry is replaced', () => { registry: new Map([['root', entry(() => null)]]), }); - result.rerender({ registry: new Map([['root', entry(() => null)]]) }); + result.rerender({ + registry: new Map([['root', entry(() => null)]]), + routesWithRemovalPrevented: new Set(), + }); expect(result.result.current.state).toBe(initialState); }); @@ -258,7 +491,11 @@ describe('NAVIGATE_TO_HREF', () => { function navigateToHref( result: ReturnType, - payload: { href?: string; options?: LinkToOptions; originalHref?: string } = {} + payload: { + href?: string; + options?: LinkToOptions; + originalHref?: string; + } = {} ) { act(() => result.result.current.processIntent({ @@ -281,7 +518,10 @@ describe('NAVIGATE_TO_HREF', () => { }); it('warns with the resolved href when the href is invalid', () => { - mockGetNavigateAction.mockReturnValue({ status: 'invalid', href: '/resolved' }); + mockGetNavigateAction.mockReturnValue({ + status: 'invalid', + href: '/resolved', + }); const result = renderReducer({ registry: new Map() }); navigateToHref(result); @@ -291,7 +531,10 @@ describe('NAVIGATE_TO_HREF', () => { }); it('warns with the original href when the href is invalid after a redirect', () => { - mockGetNavigateAction.mockReturnValue({ status: 'invalid', href: '/resolved' }); + mockGetNavigateAction.mockReturnValue({ + status: 'invalid', + href: '/resolved', + }); const result = renderReducer({ registry: new Map() }); navigateToHref(result, { originalHref: 'myapp://original' }); @@ -332,6 +575,7 @@ describe('NAVIGATE_TO_HREF', () => { routeNode: undefined, linking: undefined, redirects: undefined, + routesWithRemovalPrevented: new Set(), }, 'PUSH', true, diff --git a/packages/expo-router/src/global-state/__tests__/useNavigationTreeReportEvents.test.ios.tsx b/packages/expo-router/src/global-state/__tests__/useNavigationTreeReportEvents.test.ios.tsx new file mode 100644 index 00000000000000..f684f1fd503d02 --- /dev/null +++ b/packages/expo-router/src/global-state/__tests__/useNavigationTreeReportEvents.test.ios.tsx @@ -0,0 +1,134 @@ +import { renderHook } from '@testing-library/react-native'; +import * as React from 'react'; +import type { PropsWithChildren } from 'react'; + +import { unstable_navigationEvents } from '../../navigationEvents'; +import type { NavigationState } from '../../react-navigation/routers'; +import { PreventRemovalProvider, RemovalPreventionProvider } from '../removalPrevention'; +import type { NavigationTreeReport } from '../useNavigationTreeReducer'; +import { useNavigationTreeReportEvents } from '../useNavigationTreeReportEvents'; + +const state: NavigationState = { + stale: false, + key: 'root', + routeKeySeq: 0, + index: 0, + routeNames: ['index'], + routes: [{ key: 'index', name: 'index' }], +}; + +function wrapper({ children }: PropsWithChildren) { + return {children}; +} + +test('emits and consumes only new report events', () => { + const actions: string[] = []; + const consumeReportEvents = jest.fn(); + const unsubscribe = unstable_navigationEvents.addListener('actionDispatched', (event) => + actions.push(event.actionType) + ); + const firstEvent = { + id: 0, + type: 'action-dispatched' as const, + action: { type: 'FIRST' }, + state, + }; + const report: NavigationTreeReport = { events: [firstEvent] }; + const result = renderHook( + ({ report }: { report: NavigationTreeReport }) => + useNavigationTreeReportEvents(report, consumeReportEvents), + { wrapper, initialProps: { report } } + ); + + result.rerender({ + report: { + events: [firstEvent, { id: 1, type: 'action-dispatched', action: { type: 'SECOND' }, state }], + }, + }); + + expect(actions).toEqual(['FIRST', 'SECOND']); + expect(consumeReportEvents).toHaveBeenNthCalledWith(1, [0]); + expect(consumeReportEvents).toHaveBeenNthCalledWith(2, [1]); + unsubscribe(); +}); + +test('emits removePrevented and removed to the registered route emitters', () => { + const emitRemovalEvent = jest.fn(); + const consumeReportEvents = jest.fn(); + const action = { type: 'POP' }; + const report: NavigationTreeReport = { + events: [ + { id: 0, type: 'prevented-routes', routeKeys: ['a'], action }, + { id: 1, type: 'removed-routes', routeKeys: ['a'], action }, + ], + }; + + const result = renderHook( + ({ report }: { report: NavigationTreeReport | undefined }) => + useNavigationTreeReportEvents(report, consumeReportEvents), + { + initialProps: { report: undefined }, + wrapper: ({ children }: PropsWithChildren) => ( + + + {children} + + + ), + } + ); + result.rerender({ report }); + + expect(emitRemovalEvent).toHaveBeenNthCalledWith(1, 'a', 'removePrevented', action); + expect(emitRemovalEvent).toHaveBeenNthCalledWith(2, 'a', 'removed', action); + expect(consumeReportEvents).toHaveBeenCalledWith([0, 1]); +}); + +test('does not emit twice in StrictMode', () => { + const actions: string[] = []; + const consumeReportEvents = jest.fn(); + const unsubscribe = unstable_navigationEvents.addListener('actionDispatched', (event) => + actions.push(event.actionType) + ); + const report: NavigationTreeReport = { + events: [{ id: 0, type: 'action-dispatched', action: { type: 'FIRST' }, state }], + }; + + renderHook(() => useNavigationTreeReportEvents(report, consumeReportEvents), { + wrapper: ({ children }: PropsWithChildren) => ( + + {children} + + ), + }); + + expect(actions).toEqual(['FIRST']); + expect(consumeReportEvents).toHaveBeenCalledTimes(1); + unsubscribe(); +}); + +test('keeps emitting the remaining events when a listener throws', () => { + const actions: string[] = []; + const consumeReportEvents = jest.fn(); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const unsubscribe = unstable_navigationEvents.addListener('actionDispatched', (event) => { + actions.push(event.actionType); + if (event.actionType === 'FIRST') { + throw new Error('listener failed'); + } + }); + const report: NavigationTreeReport = { + events: [ + { id: 0, type: 'action-dispatched', action: { type: 'FIRST' }, state }, + { id: 1, type: 'action-dispatched', action: { type: 'SECOND' }, state }, + ], + }; + + renderHook(() => useNavigationTreeReportEvents(report, consumeReportEvents), { wrapper }); + + expect(actions).toEqual(['FIRST', 'SECOND']); + expect(warn).toHaveBeenCalledTimes(1); + expect(consumeReportEvents).toHaveBeenCalledWith([0, 1]); + unsubscribe(); + warn.mockRestore(); +}); diff --git a/packages/expo-router/src/global-state/removalPrevention.tsx b/packages/expo-router/src/global-state/removalPrevention.tsx new file mode 100644 index 00000000000000..3c7087971f115c --- /dev/null +++ b/packages/expo-router/src/global-state/removalPrevention.tsx @@ -0,0 +1,190 @@ +'use client'; + +import * as React from 'react'; +import { createContext, use, useMemo, useState, type PropsWithChildren } from 'react'; + +import { useClientLayoutEffect } from '../react-navigation/core/useClientLayoutEffect'; +import type { NavigationAction, NavigationState, PartialState } from '../react-navigation/routers'; + +type RemovalEventType = 'removePrevented' | 'removed'; +type RemovalEventEmitter = (type: RemovalEventType, action: NavigationAction) => void; +type RouteRemovalEventEmitter = ( + routeKey: string, + type: RemovalEventType, + action: NavigationAction +) => void; +type RemovalEventEmitterRegistry = { + registerRouteEmitter: (routeKey: string, emitter: RemovalEventEmitter) => void; + unregisterRouteEmitter: (routeKey: string, emitter: RemovalEventEmitter) => void; + emitRemovalEvent: (routeKey: string, type: RemovalEventType, action: NavigationAction) => void; +}; + +/** Provides the route keys that currently prevent removal across the navigation tree. */ +export const GlobalRoutesWithRemovalPreventedContext = createContext< + ReadonlySet | undefined +>(undefined); + +/** Publishes whether a route currently prevents removal. */ +const GlobalRouteRemovalPreventionSetterContext = createContext< + ((routeKey: string, id: string, isPrevented: boolean) => void) | null +>(null); + +/** Registers route emitters and delivers their post-commit removal events. */ +export const GlobalRemovalEventEmitterRegistryContext = + createContext(null); + +/** Registers independent prevention requests with the nearest route provider. */ +export const ScreenRemovalPreventionSetterContext = createContext< + ((id: string, isPrevented: boolean) => void) | undefined +>(undefined); + +function RemovalEventEmitterRegistryProvider({ children }: PropsWithChildren) { + const emitters = React.useRef(new Map()); + const emitterRegistry = useMemo( + () => ({ + registerRouteEmitter(routeKey, emitter) { + emitters.current.set(routeKey, emitter); + }, + unregisterRouteEmitter(routeKey, emitter) { + // Route providers unmount before post-commit `removed` delivery. Keep this emitter through + // the current task, unless another provider has already registered for the same route. + queueMicrotask(() => { + if (emitters.current.get(routeKey) === emitter) { + emitters.current.delete(routeKey); + } + }); + }, + emitRemovalEvent(routeKey, type, action) { + emitters.current.get(routeKey)?.(type, action); + }, + }), + [] + ); + + return ( + + {children} + + ); +} + +function RoutesWithRemovalPreventedProvider({ children }: PropsWithChildren) { + const [preventedRoutes, setPreventedRoutes] = useState>>( + () => new Map() + ); + const preventionSetter = React.useCallback( + (routeKey: string, id: string, isPrevented: boolean) => { + setPreventedRoutes((previous) => { + const previousIds = previous.get(routeKey) ?? new Set(); + if (previousIds.has(id) === isPrevented) { + return previous; + } + const nextIds = new Set(previousIds); + if (isPrevented) { + nextIds.add(id); + } else { + nextIds.delete(id); + } + const next = new Map(previous); + if (nextIds.size > 0) { + next.set(routeKey, nextIds); + } else { + next.delete(routeKey); + } + return next; + }); + }, + [] + ); + const preventedRouteKeys = useMemo(() => new Set(preventedRoutes.keys()), [preventedRoutes]); + + return ( + + + {children} + + + ); +} + +/** Owns the global prevented-route list and route removal-event registry. */ +export function RemovalPreventionProvider({ children }: PropsWithChildren) { + return ( + + {children} + + ); +} + +function useRegisterRouteEmitter(routeKey: string, emitRemovalEvent?: RouteRemovalEventEmitter) { + const emitterRegistry = use(GlobalRemovalEventEmitterRegistryContext); + const routeEmitter = React.useCallback( + (type, action) => emitRemovalEvent?.(routeKey, type, action), + [emitRemovalEvent, routeKey] + ); + + useClientLayoutEffect(() => { + if (!emitRemovalEvent || !emitterRegistry) { + return; + } + emitterRegistry.registerRouteEmitter(routeKey, routeEmitter); + return () => emitterRegistry.unregisterRouteEmitter(routeKey, routeEmitter); + }, [emitRemovalEvent, emitterRegistry, routeEmitter, routeKey]); +} + +function useRouteRemovalPreventionSetter(routeKey: string) { + const preventionSetter = use(GlobalRouteRemovalPreventionSetterContext); + return React.useCallback( + (id: string, isPrevented: boolean) => preventionSetter?.(routeKey, id, isPrevented), + [preventionSetter, routeKey] + ); +} + +/** Binds prevention requests and removal events to one route. */ +export function PreventRemovalProvider({ + routeKey, + emitRemovalEvent, + children, +}: PropsWithChildren<{ + routeKey: string; + emitRemovalEvent?: RouteRemovalEventEmitter; +}>) { + useRegisterRouteEmitter(routeKey, emitRemovalEvent); + const setPrevented = useRouteRemovalPreventionSetter(routeKey); + + return ( + + {children} + + ); +} + +export function useRoutesWithRemovalPrevented() { + return use(GlobalRoutesWithRemovalPreventedContext) ?? EMPTY_SET; +} + +const EMPTY_SET: ReadonlySet = new Set(); + +export function isRouteRemovalPrevented( + route: { + key: string | undefined; + state?: NavigationState | PartialState; + }, + preventedRouteKeys: ReadonlySet +): boolean { + if (route.key !== undefined && preventedRouteKeys.has(route.key)) { + return true; + } + + const visitState = (state: NavigationState): boolean => { + // TODO(@ubax): Add more generic way of filtering preloaded routes + const routes = state.type === 'stack' ? state.routes.slice(0, state.index + 1) : state.routes; + return routes.some( + (route) => + preventedRouteKeys.has(route.key) || + (route.state?.stale === false && visitState(route.state)) + ); + }; + + return route.state?.stale === false && visitState(route.state); +} diff --git a/packages/expo-router/src/global-state/routerRegistry.tsx b/packages/expo-router/src/global-state/routerRegistry.tsx index d86aebacfeb731..10d9007a85d91a 100644 --- a/packages/expo-router/src/global-state/routerRegistry.tsx +++ b/packages/expo-router/src/global-state/routerRegistry.tsx @@ -17,16 +17,6 @@ export type RouterRegistryEntry = { ) => RouterActionResult | null; shouldActionChangeFocus?: (action: NavigationAction) => boolean; getStateForRouteFocus?: (state: NavigationState, routeKey: string) => NavigationState; - shouldPreventRemove?: ( - prev: NavigationState, - next: NavigationState, - action: NavigationAction - ) => boolean; - emitBeforeRemove?: ( - prev: NavigationState, - next: NavigationState, - action: NavigationAction - ) => void; routeNode?: RouteNode; }; diff --git a/packages/expo-router/src/global-state/routingQueue.ts b/packages/expo-router/src/global-state/routingQueue.ts index 0b8aa2650aff4e..c8631bd88defac 100644 --- a/packages/expo-router/src/global-state/routingQueue.ts +++ b/packages/expo-router/src/global-state/routingQueue.ts @@ -20,15 +20,6 @@ interface RoutingIntentMetadata { export type RoutingIntent = | NavigateToHrefIntent - | { - type: 'NAVIGATOR_ACTION'; - payload: { - action: NavigationAction; - dispatchSync: (action: NavigationAction) => void; - }; - metadata?: RoutingIntentMetadata; - onDispatch?: (metadata: RoutingIntentMetadata | undefined) => void; - } | { type: 'ACTION'; payload: { action: NavigationAction; originKey?: string }; diff --git a/packages/expo-router/src/global-state/useNavigationTreeReducer.ts b/packages/expo-router/src/global-state/useNavigationTreeReducer.ts index eb6dd8e03017da..97ab8c479646d6 100644 --- a/packages/expo-router/src/global-state/useNavigationTreeReducer.ts +++ b/packages/expo-router/src/global-state/useNavigationTreeReducer.ts @@ -23,6 +23,7 @@ import type { StoreRedirects } from './types'; type ReducerConfig = { registry: RouterRegistry; + routesWithRemovalPrevented: ReadonlySet; routeNode?: RouteNode; linking?: ExpoLinkingOptions; redirects?: StoreRedirects[]; @@ -39,17 +40,54 @@ type TreeOperation = type: 'NAVIGATOR_CHANGED'; stateKey: string; routerType: string | undefined; + } + | { + type: 'REPORT_CONSUMED'; + eventIds: readonly number[]; }; type Options = { initialState: InitialState | undefined; routeNode?: RouteNode; registry: RouterRegistry; + routesWithRemovalPrevented?: ReadonlySet; linking?: ExpoLinkingOptions; redirects?: StoreRedirects[]; }; +export type NavigationTreeReport = { + events: NavigationTreeReportEvent[]; +}; + +type NavigationTreeReportEventData = + | { + type: 'removed-routes'; + routeKeys: readonly string[]; + action: NavigationAction; + } + | { + type: 'prevented-routes'; + routeKeys: readonly string[]; + action: NavigationAction; + } + | { + type: 'action-dispatched'; + action: NavigationAction; + state: NavigationState; + }; + +export type NavigationTreeReportEvent = NavigationTreeReportEventData & { + id: number; +}; + +type NavigationTreeResult = { + state: NavigationState; + report: NavigationTreeReport | undefined; + eventSeq: number; +}; + const warnedActions = new WeakSet(); +const ACTIONS_WITHOUT_REMOVAL_PREVENTION = new Set(['ROUTE_NAMES_CHANGED']); function warnIfStaleState(state: NavigationState) { if (process.env.NODE_ENV !== 'development') { @@ -107,9 +145,11 @@ function warnUnhandledAction(action: NavigationAction) { } function navigationTreeReducer( - state: NavigationState, + result: NavigationTreeResult, { operation, config }: { operation: TreeOperation; config: ReducerConfig } -): NavigationState { +): NavigationTreeResult { + const state = result.state; + switch (operation.type) { case 'NAVIGATE_TO_HREF': { const { href, options } = operation.payload; @@ -132,7 +172,7 @@ function navigationTreeReducer( console.warn( `An error occurred when trying to handle navigation action ${JSON.stringify(operation)}: ${message}` ); - return state; + return result; } if (resolution.status === 'invalid') { const invalidHref = operation.payload.originalHref ?? resolution.href; @@ -140,9 +180,9 @@ function navigationTreeReducer( console.warn( `Could not generate a valid navigation state for the given path: ${invalidHref}` ); - return state; + return result; } - return navigationTreeReducer(state, { + return navigationTreeReducer(result, { operation: { type: 'ACTION', payload: { action: resolution.action } }, config, }); @@ -159,29 +199,74 @@ function navigationTreeReducer( // TODO(@ubax): move console side effects out of the reducer and restore `onUnhandledAction`. // https://linear.app/expo/issue/ENG-26123 warnUnhandledAction(operation.payload.action); - return state; + return result; } - const result = reduceNavigationTree(operation.payload.action, config.registry, { + const reduction = reduceNavigationTree(operation.payload.action, config.registry, { origin, tree, }); - if (!result.handled) { + if (!reduction.handled) { // TODO(@ubax): move console side effects out of the reducer and restore `onUnhandledAction`. // https://linear.app/expo/issue/ENG-26123 warnUnhandledAction(operation.payload.action); - return state; + return result; } const nextState = config.routeNode - ? completeNavigationState(result.nextState, config.routeNode) - : result.nextState; - return nextState === state ? state : deepFreeze(nextState); + ? completeNavigationState(reduction.nextState, config.routeNode) + : reduction.nextState; + if (nextState === state) { + return result; + } + + const removedRoutes = getRemovedRouteKeys(state, nextState); + const preventedRoutes = ACTIONS_WITHOUT_REMOVAL_PREVENTION.has(operation.payload.action.type) + ? [] + : removedRoutes.filter((routeKey) => config.routesWithRemovalPrevented.has(routeKey)); + const committedState = preventedRoutes.length > 0 ? state : deepFreeze(nextState); + // TODO(@ubax): add dev-only diagnostics to events for dev-tools. + const eventsWithoutIds: NavigationTreeReportEventData[] = + preventedRoutes.length > 0 + ? [ + { + type: 'prevented-routes', + routeKeys: preventedRoutes, + action: operation.payload.action, + }, + ] + : [ + ...(removedRoutes.length > 0 + ? ([ + { + type: 'removed-routes', + routeKeys: removedRoutes, + action: operation.payload.action, + }, + ] satisfies NavigationTreeReportEventData[]) + : []), + { + type: 'action-dispatched', + action: operation.payload.action, + state: committedState, + }, + ]; + const events: NavigationTreeReportEvent[] = eventsWithoutIds.map((event, index) => ({ + ...event, + id: result.eventSeq + index, + })); + const report: NavigationTreeReport = { + events: result.report ? [...result.report.events, ...events] : events, + }; + + return { + state: committedState, + report, + eventSeq: result.eventSeq + events.length, + }; } - case 'NAVIGATOR_ACTION': - throw new Error('NAVIGATOR_ACTION must be dispatched through its navigator.'); case 'NAVIGATOR_UNMOUNTED': { if (!findStateByKey(state, operation.stateKey)) { - return state; + return result; } const replacement = createSeededNavigationState( undefined, @@ -192,19 +277,30 @@ function navigationTreeReducer( const completeState = config.routeNode ? completeNavigationState(nextState, config.routeNode) : nextState; - return deepFreeze(completeState); + return { ...result, state: deepFreeze(completeState) }; } case 'NAVIGATOR_CHANGED': { const navigatorState = findStateByKey(state, operation.stateKey); if (!navigatorState) { - return state; + return result; } const replacement = resetNavigatorState(navigatorState, operation.routerType); const nextState = replaceNavigationState(state, operation.stateKey, replacement); const completeState = config.routeNode ? completeNavigationState(nextState, config.routeNode) : nextState; - return deepFreeze(completeState); + return { ...result, state: deepFreeze(completeState) }; + } + case 'REPORT_CONSUMED': { + if (!result.report) { + return result; + } + const consumedIds = new Set(operation.eventIds); + const events = result.report.events.filter((event) => !consumedIds.has(event.id)); + if (events.length === result.report.events.length) { + return result; + } + return { ...result, report: events.length > 0 ? { events } : undefined }; } } } @@ -213,32 +309,39 @@ export function useNavigationTreeReducer({ initialState, routeNode, registry, + routesWithRemovalPrevented = EMPTY_SET, linking, redirects, }: Options) { - const [state, reactDispatch] = React.useReducer( + const [result, reactDispatch] = React.useReducer( navigationTreeReducer, initialState, - (value): NavigationState => { - validateInitialState(value == null ? undefined : value); + (value): NavigationTreeResult => { + validateInitialState(value); if (value == null) { throw new Error( 'The navigation container is missing its initial state. Expo Router always seeds a complete initial state before rendering the navigation container, so this is most likely a bug in expo-router. Please report it at https://github.com/expo/expo/issues.' ); } - // Validation above proves the recursively partial public type is complete. - return deepFreeze(value as NavigationState); + // TODO(@ubax): check if deepFreeze is needed here. + return { state: deepFreeze(value), report: undefined, eventSeq: 0 }; } ); - const config = React.useMemo( - () => ({ registry, routeNode, linking, redirects }), - [registry, routeNode, linking, redirects] - ); const previousRegistryRef = React.useRef(registry); const processAction = React.useCallback( - (operation: TreeOperation) => reactDispatch({ operation, config }), - [config] + (operation: TreeOperation) => + reactDispatch({ + operation, + config: { + registry, + routesWithRemovalPrevented, + routeNode, + linking, + redirects, + }, + }), + [linking, redirects, registry, routeNode, routesWithRemovalPrevented] ); const process = React.useEffectEvent(processAction); const processIntent = React.useCallback( @@ -256,35 +359,80 @@ export function useNavigationTreeReducer({ ? payload.params : undefined; warnIfScreenParam(params); - process({ type: 'ACTION', payload: { action, originKey } }); + processAction({ type: 'ACTION', payload: { action, originKey } }); }); const resetNavigator = useLatestCallback((stateKey: string, routerType: string | undefined) => { - process({ type: 'NAVIGATOR_CHANGED', stateKey, routerType }); + processAction({ type: 'NAVIGATOR_CHANGED', stateKey, routerType }); + }); + const consumeReportEvents = useLatestCallback((eventIds: readonly number[]) => { + processAction({ type: 'REPORT_CONSUMED', eventIds }); }); React.useInsertionEffect(() => { - warnIfStaleState(state); - }, [state]); + warnIfStaleState(result.state); + }, [result.state]); useClientLayoutEffect(() => { const previousRegistry = previousRegistryRef.current; previousRegistryRef.current = registry; for (const [stateKey, entry] of previousRegistry) { if (!registry.has(stateKey) && entry.routeNode) { - process({ type: 'NAVIGATOR_UNMOUNTED', stateKey, routeNode: entry.routeNode }); + process({ + type: 'NAVIGATOR_UNMOUNTED', + stateKey, + routeNode: entry.routeNode, + }); } } }, [registry]); return { - state, + state: result.state, + report: result.report, + consumeReportEvents, resetNavigator, handleAction, processIntent, }; } -function validateInitialState(state: InitialState | undefined): void { +const EMPTY_SET: ReadonlySet = new Set(); + +function getRemovedRouteKeys(current: NavigationState, next: NavigationState): string[] { + const nextRouteKeys = new Set(); + visitRoutes(next, true, (routeKey) => nextRouteKeys.add(routeKey)); + + const removedRoutes: string[] = []; + visitRoutes(current, true, (routeKey) => { + if (!nextRouteKeys.has(routeKey)) { + removedRoutes.push(routeKey); + } + }); + return removedRoutes; +} + +function visitRoutes( + state: NavigationState, + excludePreloaded: boolean, + visit: (routeKey: string) => void +) { + // TODO(@ubax): find a universal way to exclude preloaded routes. + const routes = + excludePreloaded && state.type === 'stack' + ? state.routes.slice(0, state.index + 1) + : state.routes; + for (let index = routes.length - 1; index >= 0; index--) { + const route = routes[index]!; + if (route.state?.stale === false) { + visitRoutes(route.state, excludePreloaded, visit); + } + visit(route.key); + } +} + +function validateInitialState( + state: InitialState | undefined +): asserts state is NavigationState | undefined { if (state === undefined) { return; } diff --git a/packages/expo-router/src/global-state/useNavigationTreeReportEvents.ts b/packages/expo-router/src/global-state/useNavigationTreeReportEvents.ts new file mode 100644 index 00000000000000..d5ff01dde0b242 --- /dev/null +++ b/packages/expo-router/src/global-state/useNavigationTreeReportEvents.ts @@ -0,0 +1,69 @@ +'use client'; + +import * as React from 'react'; + +import { unstable_navigationEvents } from '../navigationEvents'; +import { useClientLayoutEffect } from '../react-navigation/core/useClientLayoutEffect'; +import { GlobalRemovalEventEmitterRegistryContext } from './removalPrevention'; +import type { NavigationTreeReport } from './useNavigationTreeReducer'; + +export function useNavigationTreeReportEvents( + report: NavigationTreeReport | undefined, + consumeReportEvents: (eventIds: readonly number[]) => void +) { + const emitterRegistry = React.use(GlobalRemovalEventEmitterRegistryContext)!; + const consumedIds = React.useRef(new Set()); + + useClientLayoutEffect(() => { + const reportIds = new Set(report?.events.map((event) => event.id)); + for (const id of consumedIds.current) { + if (!reportIds.has(id)) { + consumedIds.current.delete(id); + } + } + if (report === undefined) { + return; + } + + const ids: number[] = []; + for (const event of report.events) { + if (consumedIds.current.has(event.id)) { + continue; + } + consumedIds.current.add(event.id); + ids.push(event.id); + // A listener that throws must not stop the remaining events from being delivered. + try { + switch (event.type) { + case 'prevented-routes': + for (const routeKey of event.routeKeys) { + emitterRegistry.emitRemovalEvent(routeKey, 'removePrevented', event.action); + } + break; + case 'removed-routes': + for (const routeKey of event.routeKeys) { + emitterRegistry.emitRemovalEvent(routeKey, 'removed', event.action); + } + break; + case 'action-dispatched': + // TODO(@ubax): emit an event when the action is enqueued. + unstable_navigationEvents.emit('actionDispatched', { + actionType: event.action.type, + payload: event.action.payload, + state: event.state, + }); + break; + } + } catch (error) { + const message = + typeof error === 'object' && error != null && 'message' in error ? error.message : error; + console.warn( + `An error occurred in a navigation event listener while handling ${event.type}: ${message}` + ); + } + } + if (ids.length > 0) { + consumeReportEvents(ids); + } + }, [consumeReportEvents, emitterRegistry, report]); +} diff --git a/packages/expo-router/src/layouts/experimental-stack/ExperimentalStackView.tsx b/packages/expo-router/src/layouts/experimental-stack/ExperimentalStackView.tsx index 57801386fef5c5..6b3647b7847acf 100644 --- a/packages/expo-router/src/layouts/experimental-stack/ExperimentalStackView.tsx +++ b/packages/expo-router/src/layouts/experimental-stack/ExperimentalStackView.tsx @@ -3,11 +3,14 @@ import * as React from 'react'; import { StyleSheet, View } from 'react-native'; import { Stack as ScreensStackV5 } from 'react-native-screens/experimental'; +import { + isRouteRemovalPrevented, + useRoutesWithRemovalPrevented, +} from '../../global-state/removalPrevention'; import { type ParamListBase, StackActions, type StackNavigationState, - usePreventRemoveContext, } from '../../react-navigation/native'; import { useDismissedRouteError } from '../../react-navigation/native-stack/utils/useDismissedRouteError'; import type { @@ -32,7 +35,7 @@ type Props = { export function ExperimentalStackView({ state, navigation, descriptors }: Props) { const { setNextDismissedKey } = useDismissedRouteError(state); - const { preventedRoutes } = usePreventRemoveContext(); + const routesWithRemovalPrevented = useRoutesWithRemovalPrevented(); return ( @@ -41,7 +44,7 @@ export function ExperimentalStackView({ state, navigation, descriptors }: Props) const descriptor = descriptors[route.key]!; const isPreloaded = index > state.index; const options = (descriptor.options ?? {}) as ExperimentalStackNavigationOptions; - const preventFromContext = preventedRoutes[route.key]?.preventRemove ?? false; + const preventFromContext = isRouteRemovalPrevented(route, routesWithRemovalPrevented); return ( & { @@ -66,17 +70,17 @@ const duplicateNameWarnings: string[] = []; */ export function BaseNavigationContainer(props: InternalNavigationContainerProps) { const registry = use(RouterRegistryContext); + const routesWithRemovalPrevented = use(GlobalRoutesWithRemovalPreventedContext); // TODO(@ubax): investigate if this is really needed + let content = ; + if (routesWithRemovalPrevented === undefined) { + content = {content}; + } if (registry === undefined) { - return ( - - - - ); + content = {content}; } - - return ; + return content; } function BaseNavigationContainerInner({ @@ -99,33 +103,26 @@ function BaseNavigationContainerInner({ } const registry = use(RouterRegistryContext)!; + const routesWithRemovalPrevented = use(GlobalRoutesWithRemovalPreventedContext)!; const emitter = useEventEmitter(); - // TODO(@ubax): invoke this callback from global reducer dispatches. - // https://linear.app/expo/issue/ENG-26123 - const onDispatchAction = useLatestCallback((action: NavigationAction, noop: boolean) => { - // TODO(@ubax): Capture dispatch stack traces in the expo-router devtools plugin. https://linear.app/expo/issue/ENG-20826 - emitter.emit({ - type: '__unsafe_action__', - data: { action, noop }, - }); - }); // TODO(@ubax): consider moving this state to ExpoRoot. - const { state, resetNavigator, handleAction, processIntent } = useNavigationTreeReducer({ - initialState, - routeNode: UNSTABLE_routeNode, - registry, - linking: routerConfig?.linking, - redirects: routerConfig?.redirects, - }); + const { state, report, consumeReportEvents, resetNavigator, handleAction, processIntent } = + useNavigationTreeReducer({ + initialState, + routeNode: UNSTABLE_routeNode, + registry, + routesWithRemovalPrevented, + linking: routerConfig?.linking, + redirects: routerConfig?.redirects, + }); + useNavigationTreeReportEvents(report, consumeReportEvents); const hasNotifiedInitialStateRef = React.useRef(false); const lastNotifiedStateRef = React.useRef(undefined); const { listeners, addListener } = useChildListeners(); - const { addKeyedListener } = useKeyedChildListeners(); - const dispatch = useLatestCallback((action: NavigationAction) => { if (listeners.focus[0] == null) { console.error(NOT_INITIALIZED_ERROR); @@ -135,6 +132,7 @@ function BaseNavigationContainerInner({ }); const dispatchSync = useLatestCallback((action: NavigationAction) => { + // TODO(@ubax): Throw if this is called from a `removePrevented` callback. handleAction(action); }); @@ -234,13 +232,11 @@ function BaseNavigationContainerInner({ const builderContext = React.useMemo( () => ({ addListener, - addKeyedListener, handleAction, resetNavigator, - onDispatchAction, onOptionsChange, }), - [addListener, addKeyedListener, handleAction, onDispatchAction, onOptionsChange, resetNavigator] + [addListener, handleAction, onOptionsChange, resetNavigator] ); const context = React.useMemo( diff --git a/packages/expo-router/src/react-navigation/core/NavigationBuilderContext.tsx b/packages/expo-router/src/react-navigation/core/NavigationBuilderContext.tsx index 266de82e37a01d..bc82bbd500a820 100644 --- a/packages/expo-router/src/react-navigation/core/NavigationBuilderContext.tsx +++ b/packages/expo-router/src/react-navigation/core/NavigationBuilderContext.tsx @@ -8,19 +8,8 @@ export type ListenerMap = { focus: FocusedNavigationListener; }; -export type KeyedListenerMap = { - preventRemove: ChildPreventRemoveListener; - beforeRemove: ChildBeforeRemoveListener; -}; - export type AddListener = (type: T, listener: ListenerMap[T]) => void; -export type AddKeyedListener = ( - type: T, - key: string, - listener: KeyedListenerMap[T] -) => void; - export type FocusedNavigationCallback = (navigation: NavigationHelpers) => T; export type FocusedNavigationListener = (callback: FocusedNavigationCallback) => { @@ -28,10 +17,6 @@ export type FocusedNavigationListener = (callback: FocusedNavigationCallback< result: T; }; -export type ChildPreventRemoveListener = (action: NavigationAction) => boolean; - -export type ChildBeforeRemoveListener = (action: NavigationAction) => void; - /** * Context which holds the required helpers needed to build nested navigators. */ @@ -39,12 +24,9 @@ export const NavigationBuilderContext = React.createContext<{ handleAction: (action: NavigationAction, originKey?: string) => void; resetNavigator: (stateKey: string, routerType: string | undefined) => void; addListener?: AddListener; - addKeyedListener?: AddKeyedListener; - onDispatchAction: (action: NavigationAction, noop: boolean) => void; onOptionsChange: (options: object, routeKey?: string) => void; }>({ handleAction: () => undefined, resetNavigator: () => undefined, - onDispatchAction: () => undefined, onOptionsChange: () => undefined, }); diff --git a/packages/expo-router/src/react-navigation/core/PreventRemoveContext.tsx b/packages/expo-router/src/react-navigation/core/PreventRemoveContext.tsx deleted file mode 100644 index d73527c30d2782..00000000000000 --- a/packages/expo-router/src/react-navigation/core/PreventRemoveContext.tsx +++ /dev/null @@ -1,16 +0,0 @@ -'use client'; -import * as React from 'react'; - -/** - * A type of an object that has a route key as an object key - * and a value whether to prevent that route. - */ -export type PreventedRoutes = Record; - -export const PreventRemoveContext = React.createContext< - | { - preventedRoutes: PreventedRoutes; - setPreventRemove: (id: string, routeKey: string, preventRemove: boolean) => void; - } - | undefined ->(undefined); diff --git a/packages/expo-router/src/react-navigation/core/SceneView.tsx b/packages/expo-router/src/react-navigation/core/SceneView.tsx index f08afcf47bf3af..ca09006c23687a 100644 --- a/packages/expo-router/src/react-navigation/core/SceneView.tsx +++ b/packages/expo-router/src/react-navigation/core/SceneView.tsx @@ -2,7 +2,14 @@ import * as React from 'react'; import { use } from 'react'; -import type { NavigationState, ParamListBase, PartialState, Route } from '../routers'; +import { PreventRemovalProvider } from '../../global-state/removalPrevention'; +import type { + NavigationAction, + NavigationState, + ParamListBase, + PartialState, + Route, +} from '../routers'; import { EnsureSingleNavigator } from './EnsureSingleNavigator'; import { type FocusedRouteState, @@ -20,6 +27,11 @@ type Props = { routeState: NavigationState | PartialState | undefined; options: object; clearOptions: () => void; + emitRemovalEvent: ( + routeKey: string, + type: 'removePrevented' | 'removed', + action: NavigationAction + ) => void; }; /** @@ -33,6 +45,7 @@ export function SceneView) { const { addOptionsGetter } = useOptionsGetters({ key: route.key, @@ -89,24 +102,25 @@ export function SceneView - - - - {ScreenComponent !== undefined ? ( - - ) : screen.children !== undefined ? ( - screen.children({ navigation, route }) - ) : null} - - - - + + + + + + {ScreenComponent !== undefined ? ( + + ) : screen.children !== undefined ? ( + screen.children({ navigation, route }) + ) : null} + + + + + ); } diff --git a/packages/expo-router/src/react-navigation/core/__tests__/actionBubbling.test.ios.tsx b/packages/expo-router/src/react-navigation/core/__tests__/actionBubbling.test.ios.tsx index 40d7e782f6f114..046eb9bd39657f 100644 --- a/packages/expo-router/src/react-navigation/core/__tests__/actionBubbling.test.ios.tsx +++ b/packages/expo-router/src/react-navigation/core/__tests__/actionBubbling.test.ios.tsx @@ -603,8 +603,7 @@ test.skip('logs error if no navigator handled the action', () => { spy.mockRestore(); }); -// TODO(@ubax): Restore removePrevented handling after reducer dispatch supports it. https://linear.app/expo/issue/ENG-26123 -test.skip("prevents removing a screen with 'removePrevented' event", () => { +test("prevents removing a screen with 'removePrevented' event", () => { const TestNavigator = (props: any) => { const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); @@ -713,6 +712,10 @@ test.skip("prevents removing a screen with 'removePrevented' event", () => { setPreventRemove(false); }); + expect(onStateChange).toHaveBeenCalledTimes(2); + + act(() => ref.current?.dispatchSync(StackActions.popTo('foo'))); + expect(onStateChange).toHaveBeenCalledTimes(3); expect(onStateChange).toHaveBeenCalledWith({ type: 'stack', @@ -725,8 +728,7 @@ test.skip("prevents removing a screen with 'removePrevented' event", () => { }); }); -// TODO(@ubax): Restore child removePrevented propagation after the reducer migration. https://linear.app/expo/issue/ENG-26123 -test.skip("prevents removing a child screen with 'removePrevented' event", () => { +test("prevents removing a child screen with 'removePrevented' event", () => { const TestNavigator = (props: any) => { const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); @@ -816,8 +818,7 @@ test.skip("prevents removing a child screen with 'removePrevented' event", () => expect(ref.current?.getRootState()).toEqual(preventedState); }); -// TODO(@ubax): Restore grandchild removePrevented propagation after the reducer migration. https://linear.app/expo/issue/ENG-26123 -test.skip("prevents removing a grand child screen with 'removePrevented' event", () => { +test("prevents removing a grand child screen with 'removePrevented' event", () => { const TestNavigator = (props: any) => { const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); @@ -911,8 +912,7 @@ test.skip("prevents removing a grand child screen with 'removePrevented' event", expect(ref.current?.getRootState()).toEqual(preventedState); }); -// TODO(@ubax): Restore multiple removePrevented handlers after the reducer migration. https://linear.app/expo/issue/ENG-26123 -test.skip("prevents removing by multiple screens with 'removePrevented' event", () => { +test("prevents removing by multiple screens with 'removePrevented' event", () => { const TestNavigator = (props: any) => { const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); @@ -1011,6 +1011,8 @@ test.skip("prevents removing by multiple screens with 'removePrevented' event", expect(onStateChange).toHaveBeenCalledTimes(1); expect(onBeforeRemove.lex).toHaveBeenCalledTimes(1); + expect(onBeforeRemove.baz).toHaveBeenCalledTimes(1); + expect(onBeforeRemove.bar).toHaveBeenCalledTimes(1); expect(ref.current?.getRootState()).toEqual(preventedState); @@ -1019,7 +1021,8 @@ test.skip("prevents removing by multiple screens with 'removePrevented' event", }); expect(onStateChange).toHaveBeenCalledTimes(1); - expect(onBeforeRemove.baz).toHaveBeenCalledTimes(1); + expect(onBeforeRemove.baz).toHaveBeenCalledTimes(2); + expect(onBeforeRemove.bar).toHaveBeenCalledTimes(2); expect(ref.current?.getRootState()).toEqual(preventedState); @@ -1028,13 +1031,12 @@ test.skip("prevents removing by multiple screens with 'removePrevented' event", }); expect(onStateChange).toHaveBeenCalledTimes(1); - expect(onBeforeRemove.bar).toHaveBeenCalledTimes(1); + expect(onBeforeRemove.bar).toHaveBeenCalledTimes(3); expect(ref.current?.getRootState()).toEqual(preventedState); }); -// TODO(@ubax): Restore targeted reset prevention after the reducer migration. https://linear.app/expo/issue/ENG-26123 -test.skip("prevents removing a child screen with 'removePrevented' event with targeted reset", () => { +test("prevents removing a child screen with 'removePrevented' event with targeted reset", () => { const TestNavigator = (props: any) => { const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); diff --git a/packages/expo-router/src/react-navigation/core/__tests__/removePrevented.test.ios.tsx b/packages/expo-router/src/react-navigation/core/__tests__/removePrevented.test.ios.tsx index 413bb45bb32ac8..ece7c60b6d0dc2 100644 --- a/packages/expo-router/src/react-navigation/core/__tests__/removePrevented.test.ios.tsx +++ b/packages/expo-router/src/react-navigation/core/__tests__/removePrevented.test.ios.tsx @@ -18,8 +18,7 @@ beforeEach(() => { require('nanoid/non-secure').__key = 0; }); -// TODO(@ubax): Restore removePrevented handling after reducer dispatch supports it. https://linear.app/expo/issue/ENG-26123 -test.skip('blocks removal with the hook and emits removePrevented', () => { +test('blocks removal with the hook and emits removePrevented', () => { const TestNavigator = (props: any) => { const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); return ( @@ -29,22 +28,24 @@ test.skip('blocks removal with the hook and emits removePrevented', () => { ); }; const removePrevented = jest.fn(); - const beforeRemove = jest.fn(); + const removed = jest.fn(); + const explicitlyUnsubscribedRemoved = jest.fn(); let setPreventRemove: React.Dispatch>; + let unsubscribeRemoved: () => void; const TestScreen = ({ navigation }: any) => { const [preventRemove, setPreventRemoveState] = React.useState(true); setPreventRemove = setPreventRemoveState; usePreventRemove(preventRemove, removePrevented); React.useEffect(() => navigation.addListener('removePrevented', removePrevented), [navigation]); - React.useEffect( - () => - navigation.addListener('beforeRemove', (event: any) => { - beforeRemove(event); - event.preventDefault(); - }), - [navigation] - ); + React.useEffect(() => { + const unsubscribe = navigation.addListener('removed', removed); + return () => queueMicrotask(unsubscribe); + }, [navigation]); + React.useEffect(() => { + unsubscribeRemoved = navigation.addListener('removed', explicitlyUnsubscribedRemoved); + return () => queueMicrotask(unsubscribeRemoved); + }, [navigation]); return null; }; @@ -66,19 +67,18 @@ test.skip('blocks removal with the hook and emits removePrevented', () => { expect(removePrevented).toHaveBeenCalledTimes(2); expect(removePrevented.mock.calls[0][0].data.action).toBe(action); expect(removePrevented.mock.calls[1][0].data.action).toBe(action); - expect(beforeRemove).not.toHaveBeenCalled(); + expect(removed).not.toHaveBeenCalled(); + act(() => unsubscribeRemoved()); act(() => setPreventRemove(false)); - expect(() => act(() => ref.current?.dispatchSync(CommonActions.goBack()))).toThrow( - '`beforeRemove` is a notification-only event and cannot prevent screen removal. Use `usePreventRemove` with the `removePrevented` event instead.' - ); + act(() => ref.current?.dispatchSync(CommonActions.goBack())); - expect(ref.current?.getRootState().routes.map((route) => route.name)).toEqual(['foo', 'bar']); - expect(beforeRemove).toHaveBeenCalledTimes(1); - expect(beforeRemove.mock.calls[0][0].defaultPrevented).toBe(false); + expect(ref.current?.getRootState().routes.map((route) => route.name)).toEqual(['foo']); + expect(removed).toHaveBeenCalledTimes(1); + expect(explicitlyUnsubscribedRemoved).not.toHaveBeenCalled(); }); -// TODO(@ubax): Restore removePrevented redispatch after reducer dispatch supports it. https://linear.app/expo/issue/ENG-26123 +// TODO(@ubax): prevent synchronous redispatch from a `removePrevented` callback. test.skip('blocks synchronous redispatch from removePrevented without re-emitting', () => { const TestNavigator = (props: any) => { const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); @@ -90,40 +90,30 @@ test.skip('blocks synchronous redispatch from removePrevented without re-emittin }; const ref = createNavigationContainerRef(); const removePrevented = jest.fn(({ data }) => ref.current?.dispatchSync(data.action)); - const warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); const TestScreen = () => { usePreventRemove(true, removePrevented); return null; }; - try { - render( - - - {() => null} - - - , - { wrapper: RouterRegistryProvider } - ); + render( + + + {() => null} + + + , + { wrapper: RouterRegistryProvider } + ); - act(() => ref.current?.navigate('bar')); - act(() => ref.current?.dispatchSync(CommonActions.goBack())); + act(() => ref.current?.navigate('bar')); + act(() => ref.current?.dispatchSync(CommonActions.goBack())); - expect(ref.current?.getRootState().routes.map((route) => route.name)).toEqual(['foo', 'bar']); - expect(removePrevented).toHaveBeenCalledTimes(1); - expect(warn).toHaveBeenCalledTimes(1); - expect(warn).toHaveBeenCalledWith( - "The action 'GO_BACK' was dispatched from inside a `usePreventRemove` callback and was prevented again. The `removePrevented` event was not re-emitted to avoid an infinite loop. There is no way to dispatch directly from the callback; set `preventRemove` to `false` first, then retry (for example, call `router.back()` from the handler or dispatch the captured action from an effect)." - ); - } finally { - warn.mockRestore(); - } + expect(ref.current?.getRootState().routes.map((route) => route.name)).toEqual(['foo', 'bar']); + expect(removePrevented).toHaveBeenCalledTimes(1); }); -// TODO(@ubax): Restore nested beforeRemove events after reducer dispatch supports them. https://linear.app/expo/issue/ENG-26123 -test.skip('emits beforeRemove in a nested navigator when its parent route is removed', () => { +test('emits removed in a nested navigator when its parent route is removed', () => { const TestNavigator = (props: any) => { const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); return ( @@ -132,10 +122,13 @@ test.skip('emits beforeRemove in a nested navigator when its parent route is rem ); }; - const beforeRemove = jest.fn(); + const removed = jest.fn(); const NestedScreen = ({ navigation }: any) => { - React.useEffect(() => navigation.addListener('beforeRemove', beforeRemove), [navigation]); + React.useEffect(() => { + const unsubscribe = navigation.addListener('removed', removed); + return () => queueMicrotask(unsubscribe); + }, [navigation]); return null; }; @@ -166,8 +159,10 @@ test.skip('emits beforeRemove in a nested navigator when its parent route is rem ); act(() => ref.current?.navigate('bar')); - act(() => ref.current?.goBack()); + const action = CommonActions.goBack(); + act(() => ref.current?.dispatch(action)); expect(ref.current?.getRootState().routes.map((route) => route.name)).toEqual(['foo']); - expect(beforeRemove).toHaveBeenCalledTimes(1); + expect(removed).toHaveBeenCalledTimes(1); + expect(removed.mock.calls[0][0].data.action).toBe(action); }); diff --git a/packages/expo-router/src/react-navigation/core/__tests__/removePrevented.test.web.tsx b/packages/expo-router/src/react-navigation/core/__tests__/removePrevented.test.web.tsx index 8152d312e49a3d..beb1e219afc527 100644 --- a/packages/expo-router/src/react-navigation/core/__tests__/removePrevented.test.web.tsx +++ b/packages/expo-router/src/react-navigation/core/__tests__/removePrevented.test.web.tsx @@ -9,8 +9,6 @@ import { navigationRef } from '../../../global-state/navigationRef'; import { router } from '../../../imperative-api'; import Stack from '../../../layouts/StackClient'; import { getMockContext } from '../../../testing-library/mock-config'; -import { CommonActions } from '../../routers'; -import { useNavigation } from '../useNavigation'; import { usePreventRemove } from '../usePreventRemove'; global.ResizeObserver = class { @@ -19,15 +17,15 @@ global.ResizeObserver = class { disconnect() {} } as typeof ResizeObserver; -// TODO(@ubax): Restore remove prevention after reducer dispatch supports it. https://linear.app/expo/issue/ENG-26123 -test.skip('continues a blocked router back after disabling prevention', () => { +test('allows router back after disabling prevention', () => { let discard: () => void; const onPreventRemove = jest.fn(); const Form = () => { const [dirty, setDirty] = React.useState(true); - usePreventRemove(dirty, onPreventRemove); + const disablePrevention = usePreventRemove(dirty, onPreventRemove); discard = () => { setDirty(false); + disablePrevention(); router.back(); }; return ; @@ -54,15 +52,15 @@ test.skip('continues a blocked router back after disabling prevention', () => { expect(onPreventRemove).toHaveBeenCalledTimes(1); }); -// TODO(@ubax): Restore nested remove prevention after reducer dispatch supports it. https://linear.app/expo/issue/ENG-26123 -test.skip('continues a blocked parent back after disabling nested prevention', () => { +test('allows parent back after disabling nested prevention', () => { let discard: () => void; const onPreventRemove = jest.fn(); const Form = () => { const [dirty, setDirty] = React.useState(true); - usePreventRemove(dirty, onPreventRemove); + const disablePrevention = usePreventRemove(dirty, onPreventRemove); discard = () => { setDirty(false); + disablePrevention(); router.back(); }; return ; @@ -86,36 +84,3 @@ test.skip('continues a blocked parent back after disabling nested prevention', ( expect(getRouteInfoFromState(navigationRef.getRootState()).pathname).toBe('/'); expect(onPreventRemove).toHaveBeenCalledTimes(1); }); - -// TODO(@ubax): Restore beforeRemove handling after reducer dispatch supports it. https://linear.app/expo/issue/ENG-26123 -test.skip('throws a descriptive error when beforeRemove calls preventDefault', () => { - let goBack: () => void; - const Form = () => { - const navigation = useNavigation(); - goBack = () => navigation.dispatchSync(CommonActions.goBack()); - React.useEffect( - () => - navigation.addListener('beforeRemove', (event) => { - // @ts-expect-error: legacy code treated `beforeRemove` as preventable - event.preventDefault(); - }), - [navigation] - ); - return ; - }; - - process.env.EXPO_ROUTER_IMPORT_MODE = 'sync'; - const context = getMockContext({ - _layout: () => , - index: () => , - form: Form, - }); - render(); - - act(() => router.push('/form')); - - expect(() => act(() => goBack())).toThrow( - '`beforeRemove` is a notification-only event and cannot prevent screen removal. Use `usePreventRemove` with the `removePrevented` event instead.' - ); - expect(getRouteInfoFromState(navigationRef.getRootState()).pathname).toBe('/form'); -}); diff --git a/packages/expo-router/src/react-navigation/core/__tests__/useEventEmitter.test.ios.tsx b/packages/expo-router/src/react-navigation/core/__tests__/useEventEmitter.test.ios.tsx index 2bc33cba36541d..85949beca8c5f5 100644 --- a/packages/expo-router/src/react-navigation/core/__tests__/useEventEmitter.test.ios.tsx +++ b/packages/expo-router/src/react-navigation/core/__tests__/useEventEmitter.test.ios.tsx @@ -1,8 +1,9 @@ -import { act, render } from '@testing-library/react-native'; +import { act, render, renderHook } from '@testing-library/react-native'; import * as React from 'react'; import type { NavigationState, Router } from '../../routers'; import { Screen } from '../Screen'; +import { useEventEmitter } from '../useEventEmitter'; import { useNavigationBuilder } from '../useNavigationBuilder'; import { BaseNavigationContainer } from './__fixtures__/BaseNavigationContainer'; import { MockRouter, MockRouterKey } from './__fixtures__/MockRouter'; @@ -11,6 +12,19 @@ beforeEach(() => { MockRouterKey.current = 0; }); +test('stops emitting removed events immediately after unsubscribe', () => { + const callback = jest.fn(); + const { result } = renderHook(() => + useEventEmitter<{ removed: { data: { action: { type: string } } } }>() + ); + const unsubscribe = result.current.create('route').addListener('removed', callback); + + unsubscribe(); + result.current.emit({ type: 'removed', target: 'route', data: { action: { type: 'REMOVE' } } }); + + expect(callback).not.toHaveBeenCalled(); +}); + test('fires focus and blur events in root navigator', () => { const TestNavigator = React.forwardRef(function TestNavigator(props: any, ref: any): any { const { state, navigation, descriptors, NavigationContent } = useNavigationBuilder( diff --git a/packages/expo-router/src/react-navigation/core/__tests__/useNavigationCache.test.ios.tsx b/packages/expo-router/src/react-navigation/core/__tests__/useNavigationCache.test.ios.tsx index 9c96dac5d948f0..ef244c427c0f02 100644 --- a/packages/expo-router/src/react-navigation/core/__tests__/useNavigationCache.test.ios.tsx +++ b/packages/expo-router/src/react-navigation/core/__tests__/useNavigationCache.test.ios.tsx @@ -345,13 +345,14 @@ test('uses a no-op navigation object for a preloaded stack screen', () => { expect(enqueue).toHaveBeenCalledTimes(1); expect(enqueue).toHaveBeenCalledWith({ - type: 'NAVIGATOR_ACTION', - payload: expect.objectContaining({ + type: 'ACTION', + payload: { action: expect.objectContaining({ source: expect.any(String), type: 'GO_BACK', }), - }), + originKey: expect.any(String), + }, }); expect(ref.current?.getRootState().routes.map((route) => route.name)).toEqual(['first']); }); diff --git a/packages/expo-router/src/react-navigation/core/__tests__/usePreventRemove.test.ios.tsx b/packages/expo-router/src/react-navigation/core/__tests__/usePreventRemove.test.ios.tsx index 410c8fbd903f8d..b5cbea976fa6f8 100644 --- a/packages/expo-router/src/react-navigation/core/__tests__/usePreventRemove.test.ios.tsx +++ b/packages/expo-router/src/react-navigation/core/__tests__/usePreventRemove.test.ios.tsx @@ -1,15 +1,11 @@ -import { act, render, renderHook } from '@testing-library/react-native'; +import { act, render } from '@testing-library/react-native'; import * as React from 'react'; -import { use, useEffect } from 'react'; import { CommonActions, type ParamListBase, StackActions, StackRouter } from '../../routers'; -import { type PreventedRoutes, PreventRemoveContext } from '../PreventRemoveContext'; import { Screen } from '../Screen'; import { createNavigationContainerRef } from '../createNavigationContainerRef'; import { useNavigationBuilder } from '../useNavigationBuilder'; -import { getPreventableRoutes } from '../useOnPreventRemove'; import { usePreventRemove } from '../usePreventRemove'; -import { usePreventRemoveContext } from '../usePreventRemoveContext'; import { BaseNavigationContainer } from './__fixtures__/BaseNavigationContainer'; import { MockRouterKey } from './__fixtures__/MockRouter'; @@ -19,226 +15,18 @@ jest.mock('nanoid/non-secure', () => { return m; }); +let consoleWarnSpy: jest.SpyInstance; + beforeEach(() => { MockRouterKey.current = 0; require('nanoid/non-secure').__key = 0; + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); }); -test('throws when the prevent remove context is missing', () => { - expect(() => renderHook(() => usePreventRemoveContext())).toThrow( - "Couldn't find the prevent remove context. Is your component inside NavigationContent?" - ); -}); - -test('throws when registering a route outside the navigation state', () => { - const TestNavigator = (props: any) => { - const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); - return ( - - {state.routes.map((route) => descriptors[route.key]!.render())} - - ); - }; - let setPreventRemove: NonNullable< - React.ContextType - >['setPreventRemove']; - const TestScreen = () => { - setPreventRemove = usePreventRemoveContext().setPreventRemove; - return null; - }; - - render( - - - - - - ); - - expect(() => act(() => setPreventRemove('test', 'missing', true))).toThrow( - "Couldn't find a route with the key missing. Is your component inside NavigationContent?" - ); -}); - -// TODO(@ubax): Restore preventRemove behavior for preloaded screens. https://linear.app/expo/issue/ENG-26123 -test.skip('only enables preventRemove after a preloaded screen is promoted', () => { - const TestNavigator = (props: any) => { - const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); - - return ( - - {state.routes.map((route) => descriptors[route.key]!.render())} - - ); - }; - const onPreventRemove = jest.fn(); - let preventedRoutes: PreventedRoutes | undefined; - const ProtectedScreen = () => { - usePreventRemove(true, onPreventRemove); - preventedRoutes = use(PreventRemoveContext)?.preventedRoutes; - return null; - }; - const ref = createNavigationContainerRef(); - - render( - - - {() => null} - {() => null} - - - - ); - - act(() => { - ref.current?.navigate('second'); - ref.current?.dispatch(CommonActions.preload('protected')); - }); - const preloadedRoute = ref.current?.getRootState().routes.at(-1)!; - - expect(preventedRoutes?.[preloadedRoute.key]).toBeUndefined(); - act(() => ref.current?.goBack()); - - expect(onPreventRemove).not.toHaveBeenCalled(); - expect(ref.current?.getRootState().routes.map((route) => route.name)).toEqual([ - 'first', - 'protected', - ]); - expect(ref.current?.getRootState().index).toBe(0); - - act(() => ref.current?.navigate('protected')); - const promotedState = ref.current?.getRootState(); - - expect(preventedRoutes?.[preloadedRoute.key]).toEqual({ preventRemove: true }); - act(() => ref.current?.goBack()); - - expect(onPreventRemove).toHaveBeenCalledTimes(1); - expect(ref.current?.getRootState()).toEqual(promotedState); -}); - -test('does not propagate preventRemove from a preloaded nested stack', () => { - const TestNavigator = (props: any) => { - const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); - - return ( - - {state.routes.map((route) => descriptors[route.key]!.render())} - - ); - }; - const onPreventRemove = jest.fn(); - let parentPreventedRoutes: PreventedRoutes | undefined; - const ProtectedScreen = () => { - usePreventRemove(true, onPreventRemove); - return null; - }; - const ParentPreventedRoutesObserver = () => { - parentPreventedRoutes = use(PreventRemoveContext)?.preventedRoutes; - return null; - }; - const NestedStack = (props: any) => { - const { state, descriptors, navigation, NavigationContent } = useNavigationBuilder( - StackRouter, - props - ); - - useEffect(() => navigation.dispatch(CommonActions.preload('protected')), [navigation]); - - return ( - - {state.routes.map((route) => descriptors[route.key]!.render())} - - ); - }; - const ref = createNavigationContainerRef(); - - render( - - - {() => null} - - {() => ( - <> - - - {() => null} - - - - )} - - - - ); - - act(() => ref.current?.navigate('nested')); - const nestedRoute = ref.current?.getRootState().routes.at(-1)!; - - expect(parentPreventedRoutes?.[nestedRoute.key]).toBeUndefined(); - act(() => ref.current?.goBack()); - - expect(onPreventRemove).not.toHaveBeenCalled(); - expect(ref.current?.getRootState().routes.map((route) => route.name)).toEqual(['home']); -}); - -test('only active stack routes are preventable', () => { - const routes = [ - { key: 'a', name: 'a' }, - { key: 'b', name: 'b' }, - { key: 'p1', name: 'p1' }, - { key: 'p2', name: 'p2' }, - ]; - - expect( - getPreventableRoutes({ - stale: false, - routeKeySeq: 0, - type: 'stack', - key: 'stack', - index: 1, - routeNames: routes.map((route) => route.name), - routes, - }) - ).toEqual(routes.slice(0, 2)); - - expect( - getPreventableRoutes({ - stale: false, - routeKeySeq: 0, - type: 'tab', - key: 'tabs', - index: 1, - routeNames: routes.map((route) => route.name), - routes, - }) - ).toEqual(routes); - - expect( - getPreventableRoutes( - { - index: 0, - routes, - }, - 'stack' - ) - ).toEqual(routes.slice(0, 1)); -}); +afterEach(() => consoleWarnSpy.mockRestore()); -// TODO(@ubax): Restore usePreventRemove after reducer dispatch supports it. https://linear.app/expo/issue/ENG-26123 -test.skip("prevents removing a screen with 'usePreventRemove' hook", () => { +test("prevents removing a screen with 'usePreventRemove' hook", () => { const TestNavigator = (props: any) => { const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); @@ -347,8 +135,7 @@ test.skip("prevents removing a screen with 'usePreventRemove' hook", () => { }); }); -// TODO(@ubax): Restore blocked effect dispatch after reducer dispatch supports prevention. https://linear.app/expo/issue/ENG-26123 -test.skip('dispatches a blocked action from an effect after disabling prevention', () => { +test('allows an action dispatched while disabling prevention', () => { const TestNavigator = (props: any) => { const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); return ( @@ -359,19 +146,14 @@ test.skip('dispatches a blocked action from an effect after disabling prevention }; let discard: () => void; const onPreventRemove = jest.fn(); - const TestScreen = ({ navigation }: any) => { + const TestScreen = () => { const [preventRemove, setPreventRemove] = React.useState(true); - const pendingAction = React.useRef(null); - usePreventRemove(preventRemove, ({ data }) => { - pendingAction.current = data.action; - onPreventRemove(); - }); - React.useEffect(() => { - if (!preventRemove && pendingAction.current) { - navigation.dispatch(pendingAction.current); - } - }, [navigation, preventRemove]); - discard = () => setPreventRemove(false); + const disablePrevention = usePreventRemove(preventRemove, onPreventRemove); + discard = () => { + setPreventRemove(false); + disablePrevention(); + ref.current?.goBack(); + }; return null; }; const ref = createNavigationContainerRef(); @@ -391,12 +173,139 @@ test.skip('dispatches a blocked action from an effect after disabling prevention expect(onPreventRemove).toHaveBeenCalledTimes(1); act(() => discard()); + expect(ref.current?.getRootState().routes.map((route) => route.name)).toEqual(['foo']); expect(onPreventRemove).toHaveBeenCalledTimes(1); }); -// TODO(@ubax): Restore repeated usePreventRemove registration after the reducer migration. https://linear.app/expo/issue/ENG-26123 -test.skip("prevents removing a screen when 'usePreventRemove' hook is called multiple times", () => { +test('warns when disablePrevention is called and preventRemove stays true', () => { + const TestNavigator = (props: any) => { + const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); + return ( + + {state.routes.map((route) => descriptors[route.key]!.render())} + + ); + }; + let disablePrevention!: () => void; + const TestScreen = () => { + disablePrevention = usePreventRemove(true); + return null; + }; + + render( + + + + + + ); + + act(disablePrevention); + + expect(consoleWarnSpy).toHaveBeenCalledTimes(1); +}); + +test('does not warn when preventRemove is set to false with disablePrevention', () => { + const TestNavigator = (props: any) => { + const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); + return ( + + {state.routes.map((route) => descriptors[route.key]!.render())} + + ); + }; + let discard!: () => void; + const TestScreen = () => { + const [preventRemove, setPreventRemove] = React.useState(true); + const disablePrevention = usePreventRemove(preventRemove); + discard = () => { + setPreventRemove(false); + disablePrevention(); + }; + return null; + }; + + render( + + + + + + ); + + act(discard); + + expect(consoleWarnSpy).not.toHaveBeenCalled(); +}); + +test('does not propagate prevention from a preloaded nested stack route', () => { + const TestNavigator = (props: any) => { + const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); + return ( + + {state.routes.map((route) => descriptors[route.key]!.render())} + + ); + }; + const onPreventRemove = jest.fn(); + const ProtectedScreen = () => { + usePreventRemove(true, onPreventRemove); + return null; + }; + let preloadProtected!: () => void; + const NestedStack = (props: any) => { + const { state, descriptors, navigation, NavigationContent } = useNavigationBuilder( + StackRouter, + props + ); + preloadProtected = () => navigation.dispatch(CommonActions.preload('protected')); + return ( + + {state.routes.map((route) => descriptors[route.key]!.render())} + + ); + }; + const ref = createNavigationContainerRef(); + + render( + + + {() => null} + + {() => ( + + {() => null} + + + )} + + + + ); + + act(preloadProtected); + expect(ref.current?.getRootState().routes[1]?.state?.routes.map((route) => route.name)).toEqual([ + 'index', + 'protected', + ]); + act(() => ref.current?.navigate('nested')); + act(() => ref.current?.goBack()); + + expect(onPreventRemove).not.toHaveBeenCalled(); + expect(ref.current?.getRootState().routes.map((route) => route.name)).toEqual(['home']); +}); + +test("prevents removing a screen when 'usePreventRemove' hook is called multiple times", () => { const TestNavigator = (props: any) => { const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); @@ -608,8 +517,7 @@ test("should have no effect when 'usePreventRemove' hook is set to false", () => expect(onPreventRemove).toHaveBeenCalledTimes(0); }); -// TODO(@ubax): Restore child usePreventRemove propagation after the reducer migration. https://linear.app/expo/issue/ENG-26123 -test.skip("prevents removing a child screen with 'usePreventRemove' hook", () => { +test("prevents removing a child screen with 'usePreventRemove' hook", () => { const TestNavigator = (props: any) => { const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); @@ -710,8 +618,7 @@ test.skip("prevents removing a child screen with 'usePreventRemove' hook", () => }); }); -// TODO(@ubax): Restore grandchild usePreventRemove propagation after the reducer migration. https://linear.app/expo/issue/ENG-26123 -test.skip("prevents removing a grand child screen with 'usePreventRemove' hook", () => { +test("prevents removing a grand child screen with 'usePreventRemove' hook", () => { const TestNavigator = (props: any) => { const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); @@ -812,8 +719,7 @@ test.skip("prevents removing a grand child screen with 'usePreventRemove' hook", }); }); -// TODO(@ubax): Restore multiple usePreventRemove handlers after the reducer migration. https://linear.app/expo/issue/ENG-26123 -test.skip("prevents removing by multiple screens with 'usePreventRemove' hook", () => { +test("prevents removing by multiple screens with 'usePreventRemove' hook", () => { const TestNavigator = (props: any) => { const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); @@ -906,6 +812,8 @@ test.skip("prevents removing by multiple screens with 'usePreventRemove' hook", expect(onStateChange).toHaveBeenCalledTimes(1); expect(onPreventRemove.lex).toHaveBeenCalledTimes(1); + expect(onPreventRemove.baz).toHaveBeenCalledTimes(1); + expect(onPreventRemove.bar).toHaveBeenCalledTimes(1); expect(ref.current?.getRootState()).toEqual(preventedState); @@ -916,7 +824,8 @@ test.skip("prevents removing by multiple screens with 'usePreventRemove' hook", act(() => ref.current?.dispatch(StackActions.popTo('foo'))); expect(onStateChange).toHaveBeenCalledTimes(1); - expect(onPreventRemove.baz).toHaveBeenCalledTimes(1); + expect(onPreventRemove.baz).toHaveBeenCalledTimes(2); + expect(onPreventRemove.bar).toHaveBeenCalledTimes(2); expect(ref.current?.getRootState()).toEqual(preventedState); @@ -927,7 +836,7 @@ test.skip("prevents removing by multiple screens with 'usePreventRemove' hook", act(() => ref.current?.dispatch(StackActions.popTo('foo'))); expect(onStateChange).toHaveBeenCalledTimes(1); - expect(onPreventRemove.bar).toHaveBeenCalledTimes(1); + expect(onPreventRemove.bar).toHaveBeenCalledTimes(3); expect(ref.current?.getRootState()).toEqual(preventedState); @@ -944,8 +853,7 @@ test.skip("prevents removing by multiple screens with 'usePreventRemove' hook", }); }); -// TODO(@ubax): Restore targeted reset prevention after the reducer migration. https://linear.app/expo/issue/ENG-26123 -test.skip("prevents removing a child screen with 'usePreventRemove' hook with targeted reset", () => { +test("prevents removing a child screen with 'usePreventRemove' hook with targeted reset", () => { const TestNavigator = (props: any) => { const { state, descriptors, NavigationContent } = useNavigationBuilder(StackRouter, props); diff --git a/packages/expo-router/src/react-navigation/core/index.tsx b/packages/expo-router/src/react-navigation/core/index.tsx index 2fbac45106b1c5..56e4f5978e6377 100644 --- a/packages/expo-router/src/react-navigation/core/index.tsx +++ b/packages/expo-router/src/react-navigation/core/index.tsx @@ -47,7 +47,6 @@ export { NavigationProvider } from './NavigationProvider'; * @deprecated Will be removed in a future SDK. */ export { NavigationRouteContext } from './NavigationProvider'; -export { PreventRemoveContext } from './PreventRemoveContext'; /** * @deprecated Will be removed in a future SDK. */ @@ -81,7 +80,6 @@ export { useNavigationBuilder } from './useNavigationBuilder'; export { useNavigationContainerRef } from './useNavigationContainerRef'; export { useNavigationState } from './useNavigationState'; export { usePreventRemove } from './usePreventRemove'; -export { usePreventRemoveContext } from './usePreventRemoveContext'; /** * @deprecated Import `useRoute` from `expo-router` instead. Will be removed in a future SDK. */ diff --git a/packages/expo-router/src/react-navigation/core/types.tsx b/packages/expo-router/src/react-navigation/core/types.tsx index f3696e6d206334..a2801393e03862 100644 --- a/packages/expo-router/src/react-navigation/core/types.tsx +++ b/packages/expo-router/src/react-navigation/core/types.tsx @@ -120,8 +120,24 @@ export type EventMapCore = { focus: { data: undefined }; blur: { data: undefined }; state: { data: { state: State } }; - beforeRemove: { data: { action: NavigationAction } }; removePrevented: { data: { action: NavigationAction } }; + /** + * Emitted after the route is removed and its component unmounts. The listener cannot rely on + * component state or update the unmounted component. Since effect cleanup runs before this event, + * it must defer unsubscription until the next microtask. + * + * @example + * ```tsx + * React.useEffect(() => { + * const unsubscribe = navigation.addListener('removed', (event) => { + * logRemovedRoute(event.data.action); + * }); + * + * return () => queueMicrotask(unsubscribe); + * }, [navigation]); + * ``` + */ + removed: { data: { action: NavigationAction } }; }; export type EventArg< @@ -739,23 +755,6 @@ export type NavigationContainerEventMap = { * Event that fires when current options changes. */ options: { data: { options: object } }; - /** - * Event that fires when an action is dispatched. - * Only intended for debugging purposes, don't use it for app logic. - * This event will be emitted before state changes have been applied. - */ - __unsafe_action__: { - data: { - /** - * The action object that was dispatched. - */ - action: NavigationAction; - /** - * Whether the action was a no-op, i.e. resulted in any state changes. - */ - noop: boolean; - }; - }; }; export type ParamListRoute = { diff --git a/packages/expo-router/src/react-navigation/core/useDescriptors.tsx b/packages/expo-router/src/react-navigation/core/useDescriptors.tsx index bacefc2adee3d5..e3d42b7bfeec34 100644 --- a/packages/expo-router/src/react-navigation/core/useDescriptors.tsx +++ b/packages/expo-router/src/react-navigation/core/useDescriptors.tsx @@ -10,11 +10,7 @@ import type { PartialState, Router, } from '../routers'; -import { - type AddKeyedListener, - type AddListener, - NavigationBuilderContext, -} from './NavigationBuilderContext'; +import { type AddListener, NavigationBuilderContext } from './NavigationBuilderContext'; import { NavigationProvider } from './NavigationProvider'; import { SceneView } from './SceneView'; import { ThemeContext } from './theming/ThemeContext'; @@ -22,6 +18,7 @@ import type { Descriptor, DescriptorRouteProp, EventMapBase, + EventMapCore, NavigationHelpers, NavigationProp, RouteConfig, @@ -74,9 +71,8 @@ type Options< screenLayout: ScreenLayout | undefined; state: State; addListener: AddListener; - addKeyedListener: AddKeyedListener; router: Router; - emitter: NavigationEventEmitter; + emitter: NavigationEventEmitter>; }; /** @@ -102,14 +98,12 @@ export function useDescriptors< screenLayout, state, addListener, - addKeyedListener, router, emitter, }: Options) { const theme = use(ThemeContext); const [options, setOptions] = React.useState>({}); - const { handleAction, resetNavigator, onDispatchAction, onOptionsChange } = - use(NavigationBuilderContext); + const { handleAction, resetNavigator, onOptionsChange } = use(NavigationBuilderContext); const context = React.useMemo( () => ({ @@ -117,19 +111,9 @@ export function useDescriptors< handleAction, resetNavigator, addListener, - addKeyedListener, - onDispatchAction, onOptionsChange, }), - [ - navigation, - handleAction, - resetNavigator, - addListener, - addKeyedListener, - onDispatchAction, - onOptionsChange, - ] + [navigation, handleAction, resetNavigator, addListener, onOptionsChange] ); const getNavigation = useNavigationCache({ @@ -138,10 +122,16 @@ export function useDescriptors< navigation, setOptions, router, - emitter, + // The same runtime emitter handles custom events; this generic only exposes core events here. + emitter: emitter as unknown as NavigationEventEmitter, }); const cachedRoutes = useRouteCache(routes); + const emitRemovalEvent = React.useCallback( + (routeKey: string, type: 'removePrevented' | 'removed', action: NavigationAction) => + emitter.emit({ type, target: routeKey, data: { action } }), + [emitter] + ); const getOptions = ( route: DescriptorRouteProp, @@ -225,6 +215,7 @@ export function useDescriptors< routeState={routeState} options={customOptions} clearOptions={clearOptions} + emitRemovalEvent={emitRemovalEvent} /> ); diff --git a/packages/expo-router/src/react-navigation/core/useEventEmitter.tsx b/packages/expo-router/src/react-navigation/core/useEventEmitter.tsx index 63d22fac54ec4c..9008cac4fe63bd 100644 --- a/packages/expo-router/src/react-navigation/core/useEventEmitter.tsx +++ b/packages/expo-router/src/react-navigation/core/useEventEmitter.tsx @@ -7,7 +7,7 @@ export type NavigationEventEmitter> = EventEmitter create: (target: string) => EventConsumer; }; -type Listeners = ((e: any) => void)[]; +type Listeners = Set<(e: any) => void>; /** * Hook to manage the event system used by the navigator to notify screens of various events. @@ -31,17 +31,13 @@ export function useEventEmitter>( return; } - const index = callbacks.indexOf(callback); - - if (index > -1) { - callbacks.splice(index, 1); - } + callbacks.delete(callback); }; const addListener = (type: string, callback: (data: any) => void) => { listeners.current[type] = listeners.current[type] || {}; - listeners.current[type][target] = listeners.current[type][target] || []; - listeners.current[type][target].push(callback); + listeners.current[type][target] = listeners.current[type][target] || new Set(); + listeners.current[type][target].add(callback); let removed = false; return () => { @@ -78,10 +74,8 @@ export function useEventEmitter>( // Copy the current list of callbacks in case they are mutated during execution const callbacks = target !== undefined - ? items[target]?.slice() - : ([] as Listeners) - .concat(...Object.keys(items).map((t) => items[t]!)) - .filter((cb, i, self) => self.lastIndexOf(cb) === i); + ? [...(items[target] ?? [])] + : [...new Set(Object.keys(items).flatMap((target) => [...items[target]!]))]; const event: EventArg = { get type() { @@ -125,7 +119,6 @@ export function useEventEmitter>( }, }); } else if (preventDefault) { - // Legacy `beforeRemove` listeners get a throwing shim without making the event preventable. Object.defineProperties(event, { defaultPrevented: { enumerable: true, diff --git a/packages/expo-router/src/react-navigation/core/useKeyedChildListeners.tsx b/packages/expo-router/src/react-navigation/core/useKeyedChildListeners.tsx deleted file mode 100644 index f3ad0ec1511a14..00000000000000 --- a/packages/expo-router/src/react-navigation/core/useKeyedChildListeners.tsx +++ /dev/null @@ -1,36 +0,0 @@ -'use client'; -import * as React from 'react'; - -import type { KeyedListenerMap } from './NavigationBuilderContext'; - -/** - * Hook which lets child navigators add keyed listeners. - */ -export function useKeyedChildListeners() { - const { current: keyedListeners } = React.useRef<{ - [K in keyof KeyedListenerMap]: Record; - }>( - Object.assign(Object.create(null), { - preventRemove: {}, - beforeRemove: {}, - }) - ); - - const addKeyedListener = React.useCallback( - (type: T, key: string, listener: KeyedListenerMap[T]) => { - // @ts-expect-error: according to ref stated above you can use `key` to index type - keyedListeners[type][key] = listener; - - return () => { - // @ts-expect-error: according to ref stated above you can use `key` to index type - keyedListeners[type][key] = undefined; - }; - }, - [keyedListeners] - ); - - return { - keyedListeners, - addKeyedListener, - }; -} diff --git a/packages/expo-router/src/react-navigation/core/useNavigationBuilder.tsx b/packages/expo-router/src/react-navigation/core/useNavigationBuilder.tsx index f6f701d5507991..bf48d60f990149 100644 --- a/packages/expo-router/src/react-navigation/core/useNavigationBuilder.tsx +++ b/packages/expo-router/src/react-navigation/core/useNavigationBuilder.tsx @@ -9,6 +9,7 @@ import { useComponent } from '../../fork/useComponent'; import { type RouterRegistryEntry, useRegisterRouter } from '../../global-state/routerRegistry'; import { useEnqueueRoutingIntent } from '../../global-state/routingQueueContext'; import { resetNavigatorState } from '../../global-state/stateUtils'; +import { findStateByKey } from '../../global-state/useNavigationTreeReducer'; import { type DefaultRouterOptions, type NavigationAction, @@ -25,7 +26,7 @@ import { NavigationHelpersContext } from './NavigationHelpersContext'; import { NavigationMetaContext } from './NavigationMetaContext'; import { NavigationStateContext } from './NavigationStateContext'; import { NavigatorTypeContext } from './NavigatorTypeContext'; -import { PreventRemoveContext } from './PreventRemoveContext'; +import { RootNavigationStateContext } from './RootNavigationStateContext'; import { Screen } from './Screen'; import { isArrayEqual } from './isArrayEqual'; import { @@ -44,17 +45,9 @@ import { useEventEmitter } from './useEventEmitter'; import { useFocusEvents } from './useFocusEvents'; import { useFocusedListenersChildrenAdapter } from './useFocusedListenersChildrenAdapter'; import { FocusedRouteKeyContext } from './useIsFocused'; -import { useKeyedChildListeners } from './useKeyedChildListeners'; import { useLazyValue } from './useLazyValue'; import { useNavigationHelpers } from './useNavigationHelpers'; import { NavigatorStateContext } from './useNavigationState'; -import { - emitBeforeRemove, - getPreventableRoutes, - shouldPreventRemove, - useOnPreventRemove, -} from './useOnPreventRemove'; -import { usePreventRemoveState } from './usePreventRemoveState'; import { useRegisterNavigator } from './useRegisterNavigator'; // This is to make TypeScript compiler happy @@ -262,6 +255,7 @@ export function useNavigationBuilder< useRegisterNavigator(); const routeNode = useRouteNode(); const enqueue = useEnqueueRoutingIntent(); + const { children, layout, @@ -330,6 +324,7 @@ export function useNavigationBuilder< const routeNamesKey = routeNames.join('\0'); const { state: currentState } = use(NavigationStateContext); + const rootState = use(RootNavigationStateContext); const { resetNavigator, handleAction } = use(NavigationBuilderContext); if ( @@ -343,10 +338,13 @@ export function useNavigationBuilder< ); } - const isForeignType = currentState.type !== undefined && currentState.type !== router.type; + const treeState = rootState + ? (findStateByKey(rootState, currentState.key) ?? currentState) + : currentState; + const isForeignType = treeState.type !== undefined && treeState.type !== router.type; // The reset keeps the complete fields required by every navigator state. const committedState = ( - isForeignType ? resetNavigatorState(currentState, router.type) : currentState + isForeignType ? resetNavigatorState(treeState, router.type) : treeState ) as State; const state = React.useMemo( () => router.getStateForDeclaredRoutes(committedState, routeNames), @@ -442,20 +440,6 @@ export function useNavigationBuilder< const { listeners: childListeners, addListener } = useChildListeners(); - const { keyedListeners, addKeyedListener } = useKeyedChildListeners(); - - const { isRoutePrevented, preventRemoveContextValue } = usePreventRemoveState({ - state: committedState, - }); - - useOnPreventRemove({ - state: committedState, - isRoutePrevented, - emitter, - preventRemoveListeners: keyedListeners.preventRemove, - beforeRemoveListeners: keyedListeners.beforeRemove, - }); - const onAction = React.useCallback( (action: NavigationAction) => handleAction(action, stateKeyRef.current), [handleAction] @@ -467,28 +451,9 @@ export function useNavigationBuilder< shouldActionChangeFocus: router.shouldActionChangeFocus, getStateForRouteFocus: (registryState, routeKey) => router.getStateForRouteFocus(registryState as State, routeKey), - // TODO(@ubax): invoke removal-prevention callbacks from the global reducer. - // https://linear.app/expo/issue/ENG-26123 - shouldPreventRemove: (prev, next, action) => - shouldPreventRemove( - emitter, - keyedListeners.preventRemove, - isRoutePrevented, - getPreventableRoutes(prev), - getPreventableRoutes(next, prev.type), - action - ), - emitBeforeRemove: (prev, next, action) => - emitBeforeRemove( - emitter, - keyedListeners.beforeRemove, - getPreventableRoutes(prev), - getPreventableRoutes(next, prev.type), - action - ), routeNode: routeNode ?? undefined, }), - [emitter, isRoutePrevented, keyedListeners, reduce, routeNode, routeNamesKey, router] + [reduce, routeNode, routeNamesKey, router] ); useRegisterRouter(committedState.key, registryEntry); @@ -506,7 +471,9 @@ export function useNavigationBuilder< if (isForeignType) { return; } - if (isArrayEqual(committedState.routeNames, routeNames)) { + const committed = committedState; + + if (isArrayEqual(committed.routeNames, routeNames)) { pendingRouteNamesRef.current = undefined; } else if (!isArrayEqual(pendingRouteNamesRef.current ?? [], routeNames)) { pendingRouteNamesRef.current = routeNames; @@ -516,9 +483,9 @@ export function useNavigationBuilder< action: { type: 'ROUTE_NAMES_CHANGED', payload: { routeNames }, - target: committedState.key, + target: committed.key, }, - originKey: committedState.key, + originKey: committed.key, }, }); } @@ -546,9 +513,7 @@ export function useNavigationBuilder< screenLayout, state: committedState, addListener, - addKeyedListener, router, - // @ts-expect-error: this should have both core and custom events, but too much work right now emitter, }); useCurrentRender({ @@ -573,11 +538,9 @@ export function useNavigationBuilder< - - - {element} - - + + {element} + diff --git a/packages/expo-router/src/react-navigation/core/useNavigationHelpers.tsx b/packages/expo-router/src/react-navigation/core/useNavigationHelpers.tsx index 587e6c99e2d233..2a0eae40b6d878 100644 --- a/packages/expo-router/src/react-navigation/core/useNavigationHelpers.tsx +++ b/packages/expo-router/src/react-navigation/core/useNavigationHelpers.tsx @@ -49,12 +49,8 @@ export function useNavigationHelpers< const dispatch = (action: Action) => { enqueue({ - type: 'NAVIGATOR_ACTION', - payload: { - action, - // The queued action was already constrained to this navigator's action type. - dispatchSync: (queuedAction) => dispatchSync(queuedAction as Action), - }, + type: 'ACTION', + payload: { action, originKey: getState().key }, }); }; diff --git a/packages/expo-router/src/react-navigation/core/useOnPreventRemove.tsx b/packages/expo-router/src/react-navigation/core/useOnPreventRemove.tsx deleted file mode 100644 index 5c2e537e2630ed..00000000000000 --- a/packages/expo-router/src/react-navigation/core/useOnPreventRemove.tsx +++ /dev/null @@ -1,159 +0,0 @@ -'use client'; -import * as React from 'react'; -import { use } from 'react'; - -import type { NavigationAction, NavigationState } from '../routers'; -import { - type ChildBeforeRemoveListener, - type ChildPreventRemoveListener, - NavigationBuilderContext, -} from './NavigationBuilderContext'; -import { NavigationRouteContext } from './NavigationProvider'; -import type { EventMapCore } from './types'; -import type { NavigationEventEmitter } from './useEventEmitter'; -import type { IsRoutePrevented } from './usePreventRemoveState'; - -type Options = { - state: NavigationState; - isRoutePrevented: IsRoutePrevented; - emitter: NavigationEventEmitter>; - preventRemoveListeners: Record; - beforeRemoveListeners: Record; -}; - -const VISITED_ROUTE_KEYS = Symbol('VISITED_ROUTE_KEYS'); -const emittingRemovePreventedKeys = new Set(); - -export const getPreventableRoutes = ( - state: NavigationState | { type?: string; index?: number; routes: { key?: string }[] }, - type = state.type -) => - // In order to preload routes in stack, an action needs to be dispatched, so the type will be always - // set when there are preloaded routes - type === 'stack' - ? state.routes.slice(0, (state.index ?? state.routes.length - 1) + 1) - : state.routes; - -const getRemovedRoutes = (currentRoutes: { key?: string }[], nextRoutes: { key?: string }[]) => { - const nextRouteKeys = nextRoutes.map((route) => route.key); - - return currentRoutes - .filter( - (route): route is { key: string } => - route.key !== undefined && !nextRouteKeys.includes(route.key) - ) - .reverse(); -}; - -export const shouldPreventRemove = ( - emitter: NavigationEventEmitter>, - preventRemoveListeners: Record, - isRoutePrevented: IsRoutePrevented, - currentRoutes: { key?: string }[], - nextRoutes: { key?: string }[], - action: NavigationAction -) => { - for (const route of getRemovedRoutes(currentRoutes, nextRoutes)) { - if (preventRemoveListeners[route.key]?.(action)) { - return true; - } - - if (isRoutePrevented(route.key)) { - // TODO: Queued redispatch runs after this callback and bypasses this re-entrancy guard. - // Check whether the guard is still needed now that only `dispatchSync` can re-enter it. - if (emittingRemovePreventedKeys.has(route.key)) { - if (__DEV__) { - console.warn( - `The action '${action.type}' was dispatched from inside a \`usePreventRemove\` callback and was prevented again. The \`removePrevented\` event was not re-emitted to avoid an infinite loop. There is no way to dispatch directly from the callback; set \`preventRemove\` to \`false\` first, then retry (for example, call \`router.back()\` from the handler or dispatch the captured action from an effect).` - ); - } - return true; - } - - emittingRemovePreventedKeys.add(route.key); - try { - emitter.emit({ - type: 'removePrevented', - target: route.key, - data: { action }, - }); - } finally { - emittingRemovePreventedKeys.delete(route.key); - } - return true; - } - } - - return false; -}; - -export const emitBeforeRemove = ( - emitter: NavigationEventEmitter>, - beforeRemoveListeners: Record, - currentRoutes: { key?: string }[], - nextRoutes: { key?: string }[], - action: NavigationAction -) => { - const visitedRouteKeys: Set = - // @ts-expect-error: add this property to mark that we've already emitted this action - action[VISITED_ROUTE_KEYS] ?? new Set(); - const beforeRemoveAction = { ...action, [VISITED_ROUTE_KEYS]: visitedRouteKeys }; - - for (const route of getRemovedRoutes(currentRoutes, nextRoutes)) { - if (visitedRouteKeys.has(route.key)) { - continue; - } - - beforeRemoveListeners[route.key]?.(beforeRemoveAction); - visitedRouteKeys.add(route.key); - emitter.emit({ - type: 'beforeRemove', - target: route.key, - data: { action: beforeRemoveAction }, - preventDefault() { - throw new Error( - '`beforeRemove` is a notification-only event and cannot prevent screen removal. Use `usePreventRemove` with the `removePrevented` event instead.' - ); - }, - }); - } -}; - -export function useOnPreventRemove({ - state, - isRoutePrevented, - emitter, - preventRemoveListeners, - beforeRemoveListeners, -}: Options) { - const { addKeyedListener } = use(NavigationBuilderContext); - const routeKey = use(NavigationRouteContext)?.key; - - React.useEffect(() => { - if (!routeKey) { - return; - } - - return addKeyedListener?.('preventRemove', routeKey, (action) => { - return shouldPreventRemove( - emitter, - preventRemoveListeners, - isRoutePrevented, - getPreventableRoutes(state), - [], - action - ); - }); - }, [addKeyedListener, emitter, isRoutePrevented, preventRemoveListeners, routeKey, state]); - - React.useEffect(() => { - if (!routeKey) { - return; - } - - // Forward beforeRemove into nested navigators when an ancestor removes their route. - return addKeyedListener?.('beforeRemove', routeKey, (action) => { - emitBeforeRemove(emitter, beforeRemoveListeners, getPreventableRoutes(state), [], action); - }); - }, [addKeyedListener, beforeRemoveListeners, emitter, routeKey, state]); -} diff --git a/packages/expo-router/src/react-navigation/core/usePreventRemove.tsx b/packages/expo-router/src/react-navigation/core/usePreventRemove.tsx index 6e2e8e2a9e059d..f3814917f3f555 100644 --- a/packages/expo-router/src/react-navigation/core/usePreventRemove.tsx +++ b/packages/expo-router/src/react-navigation/core/usePreventRemove.tsx @@ -1,35 +1,61 @@ 'use client'; -import { nanoid } from 'nanoid/non-secure'; import * as React from 'react'; +import { ScreenRemovalPreventionSetterContext } from '../../global-state/removalPrevention'; import useLatestCallback from '../../utils/useLatestCallback'; import type { NavigationAction } from '../routers'; import type { EventListenerCallback, EventMapCore } from './types'; +import { useClientLayoutEffect } from './useClientLayoutEffect'; import { useNavigation } from './useNavigation'; -import { usePreventRemoveContext } from './usePreventRemoveContext'; -import { useRoute } from './useRoute'; + +const NOOP = () => {}; + +function useWarnOnStalePreventRemoveDev(preventRemove: boolean) { + const [shouldCheck, setShouldCheck] = React.useState(false); + + React.useEffect(() => { + if (!shouldCheck) { + return; + } + + setShouldCheck(false); + if (preventRemove) { + console.warn( + '`disablePrevention` from `usePreventRemove` was called, but `preventRemove` is still ' + + '`true`. The screen is no longer protected, but the hook will not re-enable prevention ' + + 'until `preventRemove` changes. Set `preventRemove` to `false` in the same handler to ' + + 'keep the prop and the prevention state in sync.' + ); + } + }, [shouldCheck, preventRemove]); + + return React.useCallback(() => setShouldCheck(true), []); +} + +// Dev-only: warns when `disablePrevention` was called but `preventRemove` is still `true`. +const useWarnOnStalePreventRemove: (preventRemove: boolean) => () => void = + process.env.NODE_ENV === 'production' ? () => NOOP : useWarnOnStalePreventRemoveDev; /** * Prevents the screen from being removed while `preventRemove` is `true` and calls `callback` * with the blocked navigation action. * - * To continue, first set `preventRemove` to `false`, then call `router.back()` from the same - * press handler. To retry the blocked action, store it in the callback and dispatch it from an - * effect after `preventRemove` becomes `false`. Dispatching synchronously inside the callback - * re-triggers prevention. + * To continue from the same handler, call the returned `disablePrevention` function before + * navigating. * * @example * ```tsx * const [hasUnsavedChanges, setHasUnsavedChanges] = useState(true); * const [showConfirm, setShowConfirm] = useState(false); * - * usePreventRemove(hasUnsavedChanges, () => setShowConfirm(true)); + * const disablePrevention = usePreventRemove(hasUnsavedChanges, () => setShowConfirm(true)); * * {showConfirm && ( *