From 7996467eef35224eea32633bede007d2bcd4369d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Jul 2026 10:53:35 +0200 Subject: [PATCH 1/5] fix(mobile): Stabilize native stack option updates Co-authored-by: codex --- apps/mobile/src/native/StackHeader.tsx | 8 ++++++++ patches/@react-navigation%2Fnative-stack@7.17.6.patch | 2 ++ 2 files changed, 10 insertions(+) diff --git a/apps/mobile/src/native/StackHeader.tsx b/apps/mobile/src/native/StackHeader.tsx index e2708757995..552f0e23cdc 100644 --- a/apps/mobile/src/native/StackHeader.tsx +++ b/apps/mobile/src/native/StackHeader.tsx @@ -11,6 +11,7 @@ import { useEffect, useLayoutEffect, useMemo, + useRef, type ReactElement, type ReactNode, } from "react"; @@ -67,12 +68,19 @@ export function NativeStackScreenOptions(props: { readonly name?: string; }) { const navigation = useNativeStackNavigation(); + const lastAppliedOptionsRef = useRef(undefined); const normalizedOptions = useMemo(() => normalizeScreenOptions(props.options), [props.options]); useLayoutEffect(() => { if (!navigation || !normalizedOptions) { return; } + // Avoid re-entering navigation state when the same options object is + // reapplied every layout (common when callers pass unstable object literals). + if (lastAppliedOptionsRef.current === normalizedOptions) { + return; + } + lastAppliedOptionsRef.current = normalizedOptions; navigation.setOptions(normalizedOptions); }, [navigation, normalizedOptions]); diff --git a/patches/@react-navigation%2Fnative-stack@7.17.6.patch b/patches/@react-navigation%2Fnative-stack@7.17.6.patch index cd2b86fb76c..1ec4d978529 100644 --- a/patches/@react-navigation%2Fnative-stack@7.17.6.patch +++ b/patches/@react-navigation%2Fnative-stack@7.17.6.patch @@ -1,3 +1,5 @@ +diff --git a/lib/module/views/useHeaderConfigProps.js b/lib/module/views/useHeaderConfigProps.js +index 0b75c70b4e0d233ee3b5faaf9cfbc40d4f8ed494..eb174e3fde91a7783f132b3fb16b01175ec19220 100644 --- a/lib/module/views/useHeaderConfigProps.js +++ b/lib/module/views/useHeaderConfigProps.js @@ -19,6 +19,12 @@ From f4457a88c23fb20d934992fbd8ac696a59aa3a2d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Jul 2026 10:54:13 +0200 Subject: [PATCH 2/5] chore: update patched dependency lockfile Co-authored-by: codex --- pnpm-lock.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee737f9e750..aefdd4a6bae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -73,7 +73,7 @@ patchedDependencies: '@legendapp/list@3.2.0': 45e4cbcbeeca1b628b8480869374d7cbc77e3fbfa97ee0107a0d72c3c4a5d7f3 '@pierre/diffs@1.3.0-beta.5': 7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a '@react-native-menu/menu@2.0.0': 5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae - '@react-navigation/native-stack@7.17.6': 2d7fec7933e324ccf082b1ae0df3907c35d16d48c117d628e27d9c3921c26bca + '@react-navigation/native-stack@7.17.6': c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273 effect@4.0.0-beta.78: c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5 expo-modules-jsi@56.0.10: 9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f react-native-gesture-handler@2.31.2: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3 @@ -227,7 +227,7 @@ importers: version: 7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) '@react-navigation/native-stack': specifier: 7.17.6 - version: 7.17.6(patch_hash=2d7fec7933e324ccf082b1ae0df3907c35d16d48c117d628e27d9c3921c26bca)(a49e8e72dc3ef754b9d26038db8e6d3f) + version: 7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(a49e8e72dc3ef754b9d26038db8e6d3f) '@shikijs/core': specifier: 4.2.0 version: 4.2.0 @@ -14150,7 +14150,7 @@ snapshots: optionalDependencies: '@react-native-masked-view/masked-view': 0.3.2(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) - '@react-navigation/native-stack@7.17.6(patch_hash=2d7fec7933e324ccf082b1ae0df3907c35d16d48c117d628e27d9c3921c26bca)(a49e8e72dc3ef754b9d26038db8e6d3f)': + '@react-navigation/native-stack@7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(a49e8e72dc3ef754b9d26038db8e6d3f)': dependencies: '@react-navigation/elements': 2.9.26(c6a2ad0e2c930f8e3896e77c3ba11bc4) '@react-navigation/native': 7.3.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) From f3564666427976ed80cbb142f23f89c31f180fc1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Jul 2026 11:35:09 +0200 Subject: [PATCH 3/5] fix(mobile): Compare native stack options structurally Co-authored-by: codex --- apps/mobile/src/native/StackHeader.tsx | 49 +++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/native/StackHeader.tsx b/apps/mobile/src/native/StackHeader.tsx index 552f0e23cdc..cd451eb2573 100644 --- a/apps/mobile/src/native/StackHeader.tsx +++ b/apps/mobile/src/native/StackHeader.tsx @@ -62,25 +62,66 @@ function normalizeScreenOptions( return normalized as NativeStackNavigationOptions; } +function optionsSignature(value: unknown, seen = new WeakSet()): string { + if (value === null) return "null"; + switch (typeof value) { + case "boolean": + case "number": + case "string": + return JSON.stringify(value); + case "undefined": + return "undefined"; + case "function": + // Header factories are frequently recreated inline. Their source is + // stable across equivalent renders, while a reference comparison would + // make navigation.setOptions re-enter the navigator indefinitely. + return `function:${Function.prototype.toString.call(value)}`; + case "symbol": + return `symbol:${String(value)}`; + case "bigint": + return `bigint:${String(value)}`; + case "object": { + const object = value as object; + if (seen.has(object)) return "[circular]"; + seen.add(object); + if (Array.isArray(value)) { + return `[${value.map((entry) => optionsSignature(entry, seen)).join(",")}]`; + } + // React refs carry mutable native instances that must not make static + // screen options appear different after every render. + if ("current" in object) return "[ref]"; + return `{${Object.keys(value as Record) + .sort() + .map( + (key) => + `${JSON.stringify(key)}:${optionsSignature((value as Record)[key], seen)}`, + ) + .join(",")}}`; + } + } + return String(value); +} + export function NativeStackScreenOptions(props: { readonly options?: AppNativeStackNavigationOptions; readonly listeners?: Record void>; readonly name?: string; }) { const navigation = useNativeStackNavigation(); - const lastAppliedOptionsRef = useRef(undefined); + const lastAppliedOptionsSignatureRef = useRef(undefined); const normalizedOptions = useMemo(() => normalizeScreenOptions(props.options), [props.options]); useLayoutEffect(() => { if (!navigation || !normalizedOptions) { return; } - // Avoid re-entering navigation state when the same options object is + const signature = optionsSignature(normalizedOptions); + // Avoid re-entering navigation state when semantically equal options are // reapplied every layout (common when callers pass unstable object literals). - if (lastAppliedOptionsRef.current === normalizedOptions) { + if (lastAppliedOptionsSignatureRef.current === signature) { return; } - lastAppliedOptionsRef.current = normalizedOptions; + lastAppliedOptionsSignatureRef.current = signature; navigation.setOptions(normalizedOptions); }, [navigation, normalizedOptions]); From 26bb0991d70de6090237c064473eb395c1076221 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Jul 2026 11:44:39 +0200 Subject: [PATCH 4/5] fix(mobile): Keep native header callbacks current Co-authored-by: codex --- apps/mobile/src/native/StackHeader.tsx | 51 ++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/native/StackHeader.tsx b/apps/mobile/src/native/StackHeader.tsx index cd451eb2573..1e7e56114d6 100644 --- a/apps/mobile/src/native/StackHeader.tsx +++ b/apps/mobile/src/native/StackHeader.tsx @@ -102,6 +102,39 @@ function optionsSignature(value: unknown, seen = new WeakSet()): string return String(value); } +function stabilizeOptionFunctions( + value: unknown, + path: string, + latestFunctions: Map unknown>, + wrappers: Map unknown>, +): unknown { + if (typeof value === "function") { + latestFunctions.set(path, value as (...args: unknown[]) => unknown); + let wrapper = wrappers.get(path); + if (!wrapper) { + wrapper = (...args: unknown[]) => { + return latestFunctions.get(path)?.(...args); + }; + wrappers.set(path, wrapper); + } + return wrapper; + } + if (Array.isArray(value)) { + return value.map((entry, index) => + stabilizeOptionFunctions(entry, `${path}[${index}]`, latestFunctions, wrappers), + ); + } + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record).map(([key, entry]) => [ + key, + stabilizeOptionFunctions(entry, `${path}.${key}`, latestFunctions, wrappers), + ]), + ); + } + return value; +} + export function NativeStackScreenOptions(props: { readonly options?: AppNativeStackNavigationOptions; readonly listeners?: Record void>; @@ -109,21 +142,31 @@ export function NativeStackScreenOptions(props: { }) { const navigation = useNativeStackNavigation(); const lastAppliedOptionsSignatureRef = useRef(undefined); + const latestOptionFunctionsRef = useRef(new Map unknown>()); + const optionFunctionWrappersRef = useRef(new Map unknown>()); const normalizedOptions = useMemo(() => normalizeScreenOptions(props.options), [props.options]); + const stableOptions = normalizedOptions + ? (stabilizeOptionFunctions( + normalizedOptions, + "options", + latestOptionFunctionsRef.current, + optionFunctionWrappersRef.current, + ) as NativeStackNavigationOptions) + : undefined; useLayoutEffect(() => { - if (!navigation || !normalizedOptions) { + if (!navigation || !stableOptions) { return; } - const signature = optionsSignature(normalizedOptions); + const signature = optionsSignature(stableOptions); // Avoid re-entering navigation state when semantically equal options are // reapplied every layout (common when callers pass unstable object literals). if (lastAppliedOptionsSignatureRef.current === signature) { return; } lastAppliedOptionsSignatureRef.current = signature; - navigation.setOptions(normalizedOptions); - }, [navigation, normalizedOptions]); + navigation.setOptions(stableOptions); + }, [navigation, stableOptions]); useEffect(() => { if (!navigation || !props.listeners) { From f3d969164f734abefadbd14b40c3a33918f89784 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 16 Jul 2026 11:50:16 +0200 Subject: [PATCH 5/5] fix(mobile): Handle cyclic native stack options Co-authored-by: codex --- apps/mobile/src/native/StackHeader.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/native/StackHeader.tsx b/apps/mobile/src/native/StackHeader.tsx index 1e7e56114d6..1a8ef5a2829 100644 --- a/apps/mobile/src/native/StackHeader.tsx +++ b/apps/mobile/src/native/StackHeader.tsx @@ -107,6 +107,7 @@ function stabilizeOptionFunctions( path: string, latestFunctions: Map unknown>, wrappers: Map unknown>, + seen = new WeakSet(), ): unknown { if (typeof value === "function") { latestFunctions.set(path, value as (...args: unknown[]) => unknown); @@ -120,15 +121,19 @@ function stabilizeOptionFunctions( return wrapper; } if (Array.isArray(value)) { + if (seen.has(value)) return value; + seen.add(value); return value.map((entry, index) => - stabilizeOptionFunctions(entry, `${path}[${index}]`, latestFunctions, wrappers), + stabilizeOptionFunctions(entry, `${path}[${index}]`, latestFunctions, wrappers, seen), ); } if (value !== null && typeof value === "object") { + if (seen.has(value) || "current" in value) return value; + seen.add(value); return Object.fromEntries( Object.entries(value as Record).map(([key, entry]) => [ key, - stabilizeOptionFunctions(entry, `${path}.${key}`, latestFunctions, wrappers), + stabilizeOptionFunctions(entry, `${path}.${key}`, latestFunctions, wrappers, seen), ]), ); }