# Files changed · PR \#22521 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/22521/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[fix\(mobile\): connecting an environment no longer crashes the header](https://git.cafe/pingdotgg/t3code/pull/22521.md)

Version: 6

Head commit: aaa7380015abcf64411a887d28a596b52367001c

Base commit: 5bcc90e1116e396a86cb1802fd26361457c9548a

Comparison base: ca140c435f38612656dd655fc42e2f78efbfb4c9

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/mobile/src/native/NativeHeaderToolbar.test.tsx

Status: modified; +50; −1

```diff
@@ -1,5 +1,5 @@
 import type { NativeStackHeaderItem } from "@react-navigation/native-stack";
-import { act } from "react";
+import { act, createContext, type ReactElement } from "react";
 import { createRoot, type Root } from "react-dom/client";
 import { afterEach, expect, it, vi } from "vite-plus/test";
 
@@ -56,6 +56,43 @@
   act(() => root!.render(<DocumentToolbar path={path} placement={placement} />));
 }
 
+// Like React Navigation's NavigationStateContext, whose default value throws when
+// read. React restores a context's default outside render, so layout effects see it.
+const ContainerStateContext = createContext<object>({
+  get getIsInitial(): never {
+    throw new Error("Couldn't find a navigation context.");
+  },
+});
+const containerState = {};
+
+function ConnectingToolbar(props: { readonly label: string }) {
+  return (
+    <NativeHeaderToolbar placement="right">
+      <NativeHeaderToolbar.Custom>
+        <span aria-label={props.label} />
+      </NativeHeaderToolbar.Custom>
+    </NativeHeaderToolbar>
+  );
+}
+
+function renderConnecting(label: string) {
+  vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+  root ??= createRoot(document.createElement("div"));
+  act(() =>
+    root!.render(
+      <ContainerStateContext value={containerState}>
+        <ConnectingToolbar label={label} />
+      </ContainerStateContext>,
+    ),
+  );
+}
+
+function renderedCustomLabel() {
+  const item = harness.renderedItems[0];
+  if (item?.type !== "custom") throw new Error("Expected the custom header element");
+  return (item.element as ReactElement<{ "aria-label": string }>).props["aria-label"];
+}
+
 function openRenderedDocument() {
   const menu = harness.renderedItems[0];
   if (menu?.type !== "menu") throw new Error("Expected the native file menu");
@@ -98,3 +135,15 @@
     expect(harness.openedDocuments).toEqual(["first/guide.pdf"]);
   },
 );
+
+it("applies a custom header element rendered inside a navigation container", () => {
+  renderConnecting("Connecting");
+  expect(renderedCustomLabel()).toBe("Connecting");
+
+  const applied = harness.setOptionsCalls;
+  renderConnecting("Connecting");
+  expect(harness.setOptionsCalls).toBe(applied);
+
+  renderConnecting("Reconnecting");
+  expect(renderedCustomLabel()).toBe("Reconnecting");
+});
```

## apps/mobile/src/native/StackHeader.tsx

Status: modified; +30; −2

```diff
@@ -4,7 +4,7 @@
   NativeStackNavigationOptions,
   NativeStackNavigationProp,
 } from "@react-navigation/native-stack";
-import { use, useEffect, useLayoutEffect, useMemo, useRef } from "react";
+import { isValidElement, use, useEffect, useLayoutEffect, useMemo, useRef } from "react";
 import { NativeWorkspaceTabActivityContext } from "./v5-workspace-context";
 import type { ColorValue } from "react-native";
 
@@ -55,6 +55,29 @@
   return normalized as NativeStackNavigationOptions;
 }
 
+// React tags elements, component types and contexts with `$$typeof`; their other
+// fields are React internals. In development an element's `_owner` is a fiber that
+// reaches the whole tree, including contexts that hold their default value outside
+// render, and React Navigation's NavigationStateContext default throws when read.
+function isReactObject(value: object): boolean {
+  return "$$typeof" in value;
+}
+
+const identityIds = new WeakMap<object, number>();
+let nextIdentityId = 0;
+
+function identitySignature(value: unknown): string {
+  if (value === null || (typeof value !== "object" && typeof value !== "function")) {
+    return String(value);
+  }
+  let id = identityIds.get(value);
+  if (id === undefined) {
+    id = nextIdentityId++;
+    identityIds.set(value, id);
+  }
+  return `identity:${id}`;
+}
+
 function optionsSignature(value: unknown, seen = new WeakSet<object>()): string {
   if (value === null) return "null";
   switch (typeof value) {
@@ -75,6 +98,11 @@
       return `bigint:${String(value)}`;
     case "object": {
       const object = value as object;
+      if (isValidElement(object)) {
+        // Elements are recreated on every render, so compare what they render.
+        return `element:${identitySignature(object.type)}:${String(object.key)}:${optionsSignature(object.props, seen)}`;
+      }
+      if (isReactObject(object)) return identitySignature(object);
       if (seen.has(object)) return "[circular]";
       seen.add(object);
       if (Array.isArray(value)) {
@@ -121,7 +149,7 @@
     );
   }
   if (value !== null && typeof value === "object") {
-    if (seen.has(value) || "current" in value) return value;
+    if (seen.has(value) || "current" in value || isReactObject(value)) return value;
     seen.add(value);
     return Object.fromEntries(
       Object.entries(value as Record<string, unknown>).map(([key, entry]) => [
```
