authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-19 19:40:22+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-19 19:40:22+02:00
log271454cbd2f93f25c8c158c0bb247164a0041578
treebf9ef3ef3c14fde1e4ba8201e7a2a1e29a5e58f4
parente8ff6bc7f829911faf23074b147f95d0470d518d
signature Signed by PGP key B5690EEEBB952194

fix(mobile): Stabilize native stack option updates (#4037)

Co-authored-by: codex <codex@users.noreply.github.com>

3 files changed, 105 insertions(+), 6 deletions(-)

apps/mobile/src/native/StackHeader.tsx+100-3
...@@ -11,6 +11,7 @@ import {...@@ -11,6 +11,7 @@ import {
11 useEffect,11 useEffect,
12 useLayoutEffect,12 useLayoutEffect,
13 useMemo,13 useMemo,
14 useRef,
14 type ReactElement,15 type ReactElement,
15 type ReactNode,16 type ReactNode,
16} from "react";17} from "react";
...@@ -61,20 +62,116 @@ function normalizeScreenOptions(...@@ -61,20 +62,116 @@ function normalizeScreenOptions(
61 return normalized as NativeStackNavigationOptions;62 return normalized as NativeStackNavigationOptions;
62}63}
6364
65function optionsSignature(value: unknown, seen = new WeakSet<object>()): string {
66 if (value === null) return "null";
67 switch (typeof value) {
68 case "boolean":
69 case "number":
70 case "string":
71 return JSON.stringify(value);
72 case "undefined":
73 return "undefined";
74 case "function":
75 // Header factories are frequently recreated inline. Their source is
76 // stable across equivalent renders, while a reference comparison would
77 // make navigation.setOptions re-enter the navigator indefinitely.
78 return `function:${Function.prototype.toString.call(value)}`;
79 case "symbol":
80 return `symbol:${String(value)}`;
81 case "bigint":
82 return `bigint:${String(value)}`;
83 case "object": {
84 const object = value as object;
85 if (seen.has(object)) return "[circular]";
86 seen.add(object);
87 if (Array.isArray(value)) {
88 return `[${value.map((entry) => optionsSignature(entry, seen)).join(",")}]`;
89 }
90 // React refs carry mutable native instances that must not make static
91 // screen options appear different after every render.
92 if ("current" in object) return "[ref]";
93 return `{${Object.keys(value as Record<string, unknown>)
94 .sort()
95 .map(
96 (key) =>
97 `${JSON.stringify(key)}:${optionsSignature((value as Record<string, unknown>)[key], seen)}`,
98 )
99 .join(",")}}`;
100 }
101 }
102 return String(value);
103}
104
105function stabilizeOptionFunctions(
106 value: unknown,
107 path: string,
108 latestFunctions: Map<string, (...args: unknown[]) => unknown>,
109 wrappers: Map<string, (...args: unknown[]) => unknown>,
110 seen = new WeakSet<object>(),
111): unknown {
112 if (typeof value === "function") {
113 latestFunctions.set(path, value as (...args: unknown[]) => unknown);
114 let wrapper = wrappers.get(path);
115 if (!wrapper) {
116 wrapper = (...args: unknown[]) => {
117 return latestFunctions.get(path)?.(...args);
118 };
119 wrappers.set(path, wrapper);
120 }
121 return wrapper;
122 }
123 if (Array.isArray(value)) {
124 if (seen.has(value)) return value;
125 seen.add(value);
126 return value.map((entry, index) =>
127 stabilizeOptionFunctions(entry, `${path}[${index}]`, latestFunctions, wrappers, seen),
128 );
129 }
130 if (value !== null && typeof value === "object") {
131 if (seen.has(value) || "current" in value) return value;
132 seen.add(value);
133 return Object.fromEntries(
134 Object.entries(value as Record<string, unknown>).map(([key, entry]) => [
135 key,
136 stabilizeOptionFunctions(entry, `${path}.${key}`, latestFunctions, wrappers, seen),
137 ]),
138 );
139 }
140 return value;
141}
142
64export function NativeStackScreenOptions(props: {143export function NativeStackScreenOptions(props: {
65 readonly options?: AppNativeStackNavigationOptions;144 readonly options?: AppNativeStackNavigationOptions;
66 readonly listeners?: Record<string, (event: never) => void>;145 readonly listeners?: Record<string, (event: never) => void>;
67 readonly name?: string;146 readonly name?: string;
68}) {147}) {
69 const navigation = useNativeStackNavigation();148 const navigation = useNativeStackNavigation();
149 const lastAppliedOptionsSignatureRef = useRef<string | undefined>(undefined);
150 const latestOptionFunctionsRef = useRef(new Map<string, (...args: unknown[]) => unknown>());
151 const optionFunctionWrappersRef = useRef(new Map<string, (...args: unknown[]) => unknown>());
70 const normalizedOptions = useMemo(() => normalizeScreenOptions(props.options), [props.options]);152 const normalizedOptions = useMemo(() => normalizeScreenOptions(props.options), [props.options]);
153 const stableOptions = normalizedOptions
154 ? (stabilizeOptionFunctions(
155 normalizedOptions,
156 "options",
157 latestOptionFunctionsRef.current,
158 optionFunctionWrappersRef.current,
159 ) as NativeStackNavigationOptions)
160 : undefined;
71161
72 useLayoutEffect(() => {162 useLayoutEffect(() => {
73 if (!navigation || !normalizedOptions) {163 if (!navigation || !stableOptions) {
164 return;
165 }
166 const signature = optionsSignature(stableOptions);
167 // Avoid re-entering navigation state when semantically equal options are
168 // reapplied every layout (common when callers pass unstable object literals).
169 if (lastAppliedOptionsSignatureRef.current === signature) {
74 return;170 return;
75 }171 }
76 navigation.setOptions(normalizedOptions);172 lastAppliedOptionsSignatureRef.current = signature;
77 }, [navigation, normalizedOptions]);173 navigation.setOptions(stableOptions);
174 }, [navigation, stableOptions]);
78175
79 useEffect(() => {176 useEffect(() => {
80 if (!navigation || !props.listeners) {177 if (!navigation || !props.listeners) {
patches/@react-navigation%2Fnative-stack@7.17.6.patch+2
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1diff --git a/lib/module/views/useHeaderConfigProps.js b/lib/module/views/useHeaderConfigProps.js
2index 0b75c70b4e0d233ee3b5faaf9cfbc40d4f8ed494..eb174e3fde91a7783f132b3fb16b01175ec19220 100644
1--- a/lib/module/views/useHeaderConfigProps.js3--- a/lib/module/views/useHeaderConfigProps.js
2+++ b/lib/module/views/useHeaderConfigProps.js4+++ b/lib/module/views/useHeaderConfigProps.js
3@@ -19,6 +19,12 @@5@@ -19,6 +19,12 @@
pnpm-lock.yaml+3-3
...@@ -75,7 +75,7 @@ patchedDependencies:...@@ -75,7 +75,7 @@ patchedDependencies:
75 '@legendapp/list@3.2.0': 45e4cbcbeeca1b628b8480869374d7cbc77e3fbfa97ee0107a0d72c3c4a5d7f375 '@legendapp/list@3.2.0': 45e4cbcbeeca1b628b8480869374d7cbc77e3fbfa97ee0107a0d72c3c4a5d7f3
76 '@pierre/diffs@1.3.0-beta.5': 7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a76 '@pierre/diffs@1.3.0-beta.5': 7cb6da88544119adda056b2f46f43956f99326227732da0b345081e285a6c53a
77 '@react-native-menu/menu@2.0.0': 5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae77 '@react-native-menu/menu@2.0.0': 5ea3ae4bf1d9baf5443b65c269bb09621c27a68d556f713778f37b1e8d46aaae
78 '@react-navigation/native-stack@7.17.6': 2d7fec7933e324ccf082b1ae0df3907c35d16d48c117d628e27d9c3921c26bca78 '@react-navigation/native-stack@7.17.6': c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273
79 effect@4.0.0-beta.78: c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c579 effect@4.0.0-beta.78: c502bc684210b707dfceb87d8fe6ad6843395af6e19cfc02cd65854898bde2c5
80 expo-modules-jsi@56.0.10: 9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f80 expo-modules-jsi@56.0.10: 9170f8074ae4e35a0a086e756c8f815794fd3abe51eac67ca3ba02804225ec1f
81 react-native-gesture-handler@2.31.2: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d381 react-native-gesture-handler@2.31.2: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3
...@@ -229,7 +229,7 @@ importers:...@@ -229,7 +229,7 @@ importers:
229 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)229 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)
230 '@react-navigation/native-stack':230 '@react-navigation/native-stack':
231 specifier: 7.17.6231 specifier: 7.17.6
232 version: 7.17.6(patch_hash=2d7fec7933e324ccf082b1ae0df3907c35d16d48c117d628e27d9c3921c26bca)(a49e8e72dc3ef754b9d26038db8e6d3f)232 version: 7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(a49e8e72dc3ef754b9d26038db8e6d3f)
233 '@shikijs/core':233 '@shikijs/core':
234 specifier: 4.2.0234 specifier: 4.2.0
235 version: 4.2.0235 version: 4.2.0
...@@ -14148,7 +14148,7 @@ snapshots:...@@ -14148,7 +14148,7 @@ snapshots:
14148 optionalDependencies:14148 optionalDependencies:
14149 '@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)14149 '@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)
1415014150
14151 '@react-navigation/native-stack@7.17.6(patch_hash=2d7fec7933e324ccf082b1ae0df3907c35d16d48c117d628e27d9c3921c26bca)(a49e8e72dc3ef754b9d26038db8e6d3f)':14151 '@react-navigation/native-stack@7.17.6(patch_hash=c7fc101b78d434904425e5a24c22fb0042298dec6f807250486e784f3c717273)(a49e8e72dc3ef754b9d26038db8e6d3f)':
14152 dependencies:14152 dependencies:
14153 '@react-navigation/elements': 2.9.26(c6a2ad0e2c930f8e3896e77c3ba11bc4)14153 '@react-navigation/elements': 2.9.26(c6a2ad0e2c930f8e3896e77c3ba11bc4)
14154 '@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)14154 '@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)