Skip to content

Fix uncontrolled multiline TextInput not resizing when children change#56291

Open
janicduplessis wants to merge 1 commit into
react:mainfrom
janicduplessis:fix/textinput-uncontrolled-measure
Open

Fix uncontrolled multiline TextInput not resizing when children change#56291
janicduplessis wants to merge 1 commit into
react:mainfrom
janicduplessis:fix/textinput-uncontrolled-measure

Conversation

@janicduplessis

@janicduplessis janicduplessis commented Mar 31, 2026

Copy link
Copy Markdown
Contributor

Summary:

When a multiline TextInput is uncontrolled (no value prop) and uses children for styled text, changing the children does not cause the TextInput to resize. For example, if the children go from multiline content to empty, the input stays at its expanded height.

Root cause

During a layout pass, YGNodeCalculateLayout calls measureContent() which uses attributedStringBoxToMeasure() to decide what text to measure. For uncontrolled TextInputs with Text children, attributedStringBox in state holds the native text from the last _updateState() call — but by the time the React tree children have changed (e.g. cleared), attributedStringBox still contains the old native text.

updateStateIfNeeded() would normally sync the state, but it runs in layout() which is called after YGNodeCalculateLayout has already computed sizes. So Yoga measures with stale text and the height doesn't update.

Fix

In attributedStringBoxToMeasure(), for uncontrolled TextInputs (props.text is empty), check if the React tree attributed string has diverged from what's stored in state. If so, use the React tree version for measurement instead of the stale attributedStringBox.

The check is guarded by props.text.empty() so controlled TextInputs (which are the common case and don't have this issue) skip it entirely with zero overhead.

Changelog:

[IOS][FIXED] - Fix uncontrolled multiline TextInput not resizing when children change

Test Plan:

  1. Create an uncontrolled multiline TextInput with children:
const [text, setText] = useState('');
<TextInput multiline onChangeText={setText}>
  <Text>{text}</Text>
</TextInput>
<Button title="Clear" onPress={() => setText('')} />
  1. Type enough text to make the input expand to multiple lines
  2. Press "Clear" to empty the text
  3. Before fix: Input stays at its expanded height
  4. After fix: Input shrinks back to single-line height

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Mar 31, 2026
@facebook-github-tools facebook-github-tools Bot added Contributor A React Native contributor. Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. labels Mar 31, 2026
@janicduplessis janicduplessis force-pushed the fix/textinput-uncontrolled-measure branch 2 times, most recently from 4bbb042 to f800915 Compare March 31, 2026 15:04
## Summary

When a multiline TextInput uses children (attributed text) instead of the
`value` prop (uncontrolled mode), changing the children does not cause the
TextInput to resize. For example, clearing children after sending a message
leaves the input at its expanded multi-line height.

## Root Cause

`measureContent()` is called by Yoga during `YGNodeCalculateLayout`, which
runs BEFORE `updateStateIfNeeded()` in the `layout()` callback. In
`attributedStringBoxToMeasure()`, when the state is meaningful and the
`attributedStringBox` is non-empty, it returns the stale native text for
measurement — even though the React tree (children) has already changed.

The sequence:
1. User types multiline text → native `_updateState()` updates
   `attributedStringBox` with the typed text
2. User clears text (e.g. sends message) → React children become empty
3. Yoga calls `measureContent()` → `attributedStringBoxToMeasure()` returns
   the stale multi-line text → height stays expanded
4. `updateStateIfNeeded()` runs later and updates state, but the layout
   has already been computed with the wrong size

## Fix

In `attributedStringBoxToMeasure()`, check if the React tree attributed
string has diverged from what's stored in state. If so, use the React tree
version for measurement instead of the stale `attributedStringBox`. This
ensures Yoga measures with the correct content when children change between
layout passes.

## Changelog
[iOS][Fixed] - Fix uncontrolled multiline TextInput not resizing when children change

Fixes react#54570
@janicduplessis janicduplessis force-pushed the fix/textinput-uncontrolled-measure branch from f800915 to adb184c Compare March 31, 2026 15:07
janicduplessis added a commit to tloncorp/tlon-apps that referenced this pull request Mar 31, 2026
In Fabric, measureContent() is called by Yoga during YGNodeCalculateLayout
before updateStateIfNeeded() runs. For uncontrolled TextInputs (no value
prop), attributedStringBoxToMeasure() returns the stale native text from
attributedStringBox even when React children have changed, causing the
TextInput to not resize (e.g. staying expanded after clearing text).

The patch checks if the React tree attributed string has diverged from
state for uncontrolled inputs and uses it for measurement instead.

Upstream PR: react/react-native#56291
Fixes TLON-5492
@terrysahaidak

Copy link
Copy Markdown

Can confirm the fix is working for us on 0.83.2

@github-actions

Copy link
Copy Markdown

Warning

JavaScript API change detected

This PR commits an update to ReactNativeApi.d.ts, indicating a change to React Native's public JavaScript API.

  • Please include a clear changelog message.
  • This change will be subject to additional review.

This change was flagged as: POTENTIALLY_BREAKING

@victoriomolina

Copy link
Copy Markdown

LGTM

dnbrwstr added a commit to tloncorp/tlon-apps that referenced this pull request May 6, 2026
* Update Node version to 22.22.0

* Upgrade to Expo 54, React Native 0.81, and React 19

Major version upgrades:
- Expo 52 → 54
- React Native 0.73 → 0.81
- React 18 → 19
- React Navigation 6 → 7
- PostHog and other dependencies updated for compatibility

* iOS: Migrate AppDelegate from Objective-C to Swift

- Delete AppDelegate.h and AppDelegate.mm
- Add AppDelegate.swift with equivalent functionality
- Remove main.m (Swift uses @main attribute)
- Update Xcode project configuration

* Android: Migrate MainActivity and MainApplication from Java to Kotlin

- Convert MainActivity.java to MainActivity.kt
- Convert MainApplication.java to MainApplication.kt
- Preserve all functionality including window insets handling

* Update build configuration for Expo 54

Mobile:
- Update app.config.ts for Expo 54 plugins
- Update babel.config.js for new architecture
- Update metro.config.js with new resolver config
- Update Android gradle and build configuration
- Update iOS Info.plist and bridging headers

Web:
- Update Vite config for React Native Worklets

* Mobile: Rename index.js to index.tsx

Expo 54 supports TypeScript entry files

* Move Cosmos config to mobile app directory

- Move cosmos.imports.ts from root to apps/tlon-mobile/
- Add cosmos.config.json to tlon-mobile app
- Remove root cosmos.imports.ts

* Update code for React 19 and React Navigation v7 compatibility

- Refactor navigation logging to work with React Navigation v7
- Remove unnecessary React imports (React 19 auto-imports)
- Update TypeScript config for React 19
- Fix navigation context usage

* Update PostHog integration for Expo 54

- Refactor PostHog initialization to synchronous pattern
- Update telemetry provider for new PostHog API
- Fix type compatibility with new PostHog version

* Fix type errors and update API usage for Expo 54

- Update Cosmos exports for ES module compatibility
- Fix TypeScript errors in signup context and API calls
- Update contacts API for new Expo Contacts module
- Fix attestation domain types

* Remove Expo 52 patch and update .gitignore

- Remove expo@52.0.47.patch (no longer needed in Expo 54)
- Add .expo directory to .gitignore

* Update lockfiles for Expo 54 upgrade

- Update pnpm-lock.yaml with new dependency versions
- Update iOS Podfile.lock
- Update Android gradle.lockfile

* Update Tamagui sheet patch to version 1.126.18

- Rename patch from @tamagui__sheet@1.126.12 to @tamagui__sheet@1.126.18
- Update pnpm.patchedDependencies reference
- Update lockfiles with new dependency resolutions
- Minor dependency version bumps from pnpm install

* Mobile: Update iOS bundle generation to use Expo CLI

- Replace 'react-native bundle' with 'expo export:embed'
- Remove outdated entry-file path and dev flag
- Expo CLI automatically handles entry point detection

* Migrate from @react-native-clipboard/clipboard to expo-clipboard

- Replace all imports with expo-clipboard
- Update Clipboard.setString() to Clipboard.setStringAsync()
- Update Clipboard.getString() to Clipboard.getStringAsync()
- Update Clipboard.hasImage() to Clipboard.hasImageAsync()
- Update Clipboard.getImagePNG/JPG() to Clipboard.getImageAsync({ format })
- Update test mocks to use expo-clipboard mock
- Make callbacks async where needed for await usage

* Revert Tamagui to 1.126.12 with strict version locking

- Revert @tamagui/sheet patch from 1.126.18 to 1.126.12
- Lock all Tamagui dependencies to exact 1.126.12 (no range)
- Update all package.json files to use strict version (removed ~)
- Regenerate lockfiles with clean install
- All Tamagui packages now on exact 1.126.12

* Revert Tamagui to ~1.126.12 range and add react-native-picker

- Change Tamagui versions from exact 1.126.12 to ~1.126.12 range
  - @tamagui/react-native-media-driver
  - @tamagui/babel-plugin
  - @tamagui/vite-plugin
- Add @react-native-picker/picker@^2.11.4 as explicit dependency
  - Was peer dependency of react-native-phone-input
  - Now explicitly managed for Expo 54 compatibility
- Update Android build scripts: productionDebugOptimized → productionDebug
- Regenerate lockfiles

* Fix android:preview script to use previewDebug variant

* Fix PostHog provider and clean up formatting

- TelemetryProvider: Remove null check, use disabled option for tests
- AppInfoScreen: Format upload logs button
- tsconfig: Remove expo/tsconfig.base extend, add JSDoc comment

* Migrate expo-background-task patch from 0.1.4 to 1.0.10

Changes applied:
- Android: Set DEFAULT_INTERVAL_MINUTES to 20 minutes (was 24 hours)
- iOS: Set intervalSeconds to 15 minutes (was 12 hours)
- iOS: Change from BGProcessingTaskRequest to BGAppRefreshTaskRequest
- iOS: Change background mode check from 'processing' to 'fetch'
- iOS: Set earliestBeginDate to nil for immediate scheduling
- iOS: Add debug print statements for task lifecycle
- Remove network/power requirements from iOS task requests

Removed obsolete patches:
- expo-background-task@0.1.4.patch
- @react-navigation__drawer@6.7.2.patch
- expo-localization@16.0.1.patch
- react-native-reanimated@3.8.1.patch
- react-native@0.73.4.patch

These were automatically removed as those versions are no longer installed.

* Update rube Dockerfile Node.js setup to v22

* Upgrade Tamagui to v2.0.0-rc.0 and fix React 19 compatibility

- Upgrade @tamagui/* packages from ~1.126.12 to ~2.0.0-rc.0
- Remove @tamagui/sheet patch (no longer needed in v2)

Tamagui v2 API changes:
- Replace Stack with View/YStack (Stack removed in v2)
- Replace animation prop with transition on animated components
- Replace tag prop with render (renamed in v2)
- Replace editable prop with readOnly on TextArea/Input
- Replace onHoverIn/onHoverOut with onMouseEnter/onMouseLeave
- Remove textWrap/wordWrap non-existent props
- Remove fontWeight from Button (stack-based, not text)
- Fix TransitionProp by adding medium/slow animation keys to config

React 19 type compatibility:
- JSX.Element → ReactNode/ReactElement for children props
- RefObject<T> → RefObject<T | null> for ref nullability
- Fix generic types for forwardRef components

React Native API updates:
- BackHandler.addEventListener returns NativeEventSubscription
- headerBackTitleVisible → headerBackButtonDisplayMode: 'minimal'
- expo-contacts types: Contact → ExistingContact

Component fixes:
- Update ButtonContext/FloatingActionButton to use useStyledContext()
- Fix Pressable navigation props (href/action typing)
- Fix OverflowTriggerButton forwardRef generic type
- Cast web-only outlineStyle in BareChatInput
- Provide defaultTheme fallback in BaseProviderStack
- Remove duplicate clipboard image format attempt
- Update react-native-country-codes-picker patch for JSX.Element
- Add fontFamily to ListItemTitle for consistency
- Add position="relative" for absolute positioning contexts

* Add customizable hoverStyle prop to ChannelListItem and GroupListItem

Allow overriding the default hover background color by accepting
a hoverStyle prop, defaulting to the original behavior if not provided.

* Fix React 19 and React Native compatibility issues

- Fix useRef initialization to explicitly pass undefined
- Update BackHandler event listener cleanup to use new API
- Fix ts-expect-error placement in PhoneNumberInput
- Add type cast for RawBottomSheetTextInput ref

* Fix setTimeout type conflict in run-selected-tests.ts

Replace NodeJS.Timeout type with ReturnType<typeof setTimeout> to resolve
compilation error when DOM types are present in tsconfig. This makes the
type declaration work correctly in both DOM and Node.js environments.

* Fix production build: add vite preview proxy and expo polyfill

- Add preview.proxy to vite config so vite preview proxies API requests
  to the Urbit ship (the urbit plugin only sets server.proxy for dev)
- Add resolveId hook to reactNativeWebPlugin to prefer .web.ts index
  files for directory imports in node_modules (fixes Rollup resolving
  expo-modules-core polyfill/index.ts noop instead of index.web.ts)
- Add explicit expo-polyfill.ts imported first in main.tsx to ensure
  globalThis.expo is set up before any expo modules load

* Fix Tamagui v2 dialog rendering and env var issues on web

- Add envPrefix ['VITE_', 'TAMAGUI_'] to vite config to prevent
  Tamagui plugin from overriding Vite's default VITE_ prefix
- Fix ActionSheet dialog: add disableRemoveScroll to prevent z-index
  stacking issues, change ScrollView flex to flexShrink to fix
  0-height content rendering
- Patch @tamagui/dialog to remove render: 'dialog' on DialogPortalFrame
  which causes stacking context issues with native <dialog> element
- Fix GroupTypeCard text overlap by disabling text trimming margins

* Fix dialog overlay blocking pointer events in all Dialog usages

The previous fix only addressed ActionSheet, but ConfirmDialog (used for
delete group confirmation) had the same issue. Add pointerEvents="none"
to Dialog.Overlay in both ActionSheet and ConfirmDialog so overlays never
intercept clicks, regardless of whether the pnpm patch is applied.

Also make e2e test cleanup more robust by pressing Escape to dismiss any
lingering dialogs before attempting to interact with background elements.

* Bump Node.js version to 22 in EAS build config

* Bump Node.js version to 22.22.0 in EAS build config

Fixes any-ascii ESM crash during Tamagui static extraction on EAS.
Node 22.12.0 had buggy require(esm) interop that caused uncatchable
errors with esbuild-register.

* Bring back legacy styling for tamagui

* Fix React Navigation v7 navigate not popping back to existing screens

React Nav v7 changed navigate() to push new screens instead of popping
back to existing ones in a stack. This caused duplicate ChannelRoot
screens (and 2 MessageInput textareas) when navigating back to a channel
from GroupSettings via the sidebar.

- Add pop: true to getDesktopChannelRoute nested params so navigate()
  pops back to existing ChannelRoot instead of pushing a new one
- Add pop: true to useNavigateBackFromPost desktop path
- Fix navigateToGroupSettings to navigate directly to Channel >
  GroupSettings in a single call instead of the broken 2-step approach
  (navigateToGroup + setTimeout with stale navigation ref)

* Fix React Navigation v7 nested navigator stale screens and other upgrade issues

React Navigation v7 changed how nested navigator state is handled:
in v6, navigating with `params: { screen, params }` would reset the
nested navigator state. In v7, it dispatches `CommonActions.navigate()`
which pushes onto the existing stack, causing stale screens to
accumulate.

The fix uses `params: { state: { routes: [...], index: 0 } }` which
triggers `CommonActions.reset()` to fully replace the nested state,
matching v6 behavior. This is applied to all GroupSettings stack
navigations.

Also adds `pop: true` to navigate calls that should pop back to
existing screens (restoring v6 popTo behavior), and fixes several
other issues from the Expo 54 / RN v7 upgrade:

- Port @tamagui/sheet patch to v2.0.0-rc.0
- Move ForwardPostSheetProvider inside NavigationContainer
- Fix ActionSheet Popover z-index behind modals
- Add navigation state debug logging
- Refactor e2e tests to use navigateBack helper

* Fix navigateBack e2e helper for NativeStack on web

NativeStack on web renders all stacked screens in the DOM, creating
many HeaderBackButton elements. The old helper only checked indices
[2, 1, 0] which missed the correct button when 5+ screens were stacked.

Now dynamically counts all back buttons and clicks the last visible one
(the topmost screen). Also fix roles-management test back-navigation
loops to stop once they reach the group channels view instead of
blindly navigating back a fixed number of times.

* Fix useMarkdownMode editorRef type for React 19 useRef nullability

* Cleanups

* Clean up navigation logger to match base branch logging style

* Restore and update gradle.lockfile for Expo 54 deps

* Update Tamagui to v2.0.0-rc.22 and fix breaking changes

- Sheet `animation` prop renamed to `transition`
- Remove `estimatedItemSize` (FlashList 2.0 auto-calculates)
- Remove unused `editorIsFocused` prop from DetailView
- Fix expo-sensors version for Expo 54
- Update sheet patch for new version

* update podfile

* cleanup

* Revert some changes

* fix ts

* fix: replace barrel imports with direct paths to break circular deps

Change packages/api and packages/shared db files to import from
specific module paths instead of barrel re-exports to avoid circular
dependency issues with Metro/Vite.

* fix: revert scheme LaunchAction to Debug build configuration

* fix: pin expo-audio to ~1.1.1 and remove stale patches

Add expo-audio override to prevent pnpm from resolving the `*` peer dep
in packages/app to v55, which passes 4 constructor args to the v1.1.1
native module that only expects 3. Also remove the now-unnecessary
expo-audio JS patch and update expo-task-manager patch for v14.

* fix: update lockfiles and dependencies for Expo 54

- Add expo-audio, expo-speech-transcriber, expo-video deps to mobile
- Update gradle and Podfile lockfiles
- Remove unused Stack import in MediaViewerScreen
- Regenerate cosmos imports

* fix: upgrade TypeScript to ~5.9.2 and fix type errors

Upgrade TS to match Expo SDK 54 template, fixing expo-file-system
Uint8Array generic errors. Also fix:
- StackProps -> ViewProps in Pressable (removed in Tamagui v2)
- ResultState cast in navigation intent
- client -> posthog in posthog.ts
- Canvas onLayout wrapped in View in Waveform
- OverflowTriggerButton explicit type annotation
- NodeJS.Timeout -> ReturnType<typeof setTimeout> for TS 5.9

* fixes

* fix: revert unnecessary circular dep import changes in packages/api

These direct-path imports are no longer needed and were causing lint
issues after rebasing on develop.

* Fix preview app splash

* fixes

* fix tests

* fix: use pnpm rebuild in test scripts and update expo-file-system mock

Replace npm rebuild with pnpm rebuild to avoid broken symlink traversal
in pnpm's virtual store. Update test mock to use expo-file-system/legacy.

* fix: target npm rebuild to better-sqlite3 only in test scripts

pnpm rebuild skips better-sqlite3 native addon, and blanket npm rebuild
chokes on broken esbuild symlinks in pnpm's virtual store. Targeting
just better-sqlite3 avoids both issues.

* fix: restore VITE_* env vars blocked by Tamagui vite plugin

@tamagui/vite-plugin overrides envPrefix to ["TAMAGUI_"], which blocks
all VITE_* env vars from import.meta.env. This caused
VITE_DISABLE_SPLASH_MODAL to be ignored, leaving the splash modal
overlay blocking all e2e test clicks.

* fix(e2e): update roles-management back loop for React Navigation v7

The back loop was using a GroupOptionsSheetTrigger testID that was
renamed to GroupChannelsHeaderTrigger. Updated to match develop's
HeaderBackButton approach, using .last() instead of .first() since
v7 renders all stacked screens in the DOM simultaneously.

* fix: remove centering from Modal ZStack that broke long-press message actions

The justifyContent="center" and alignItems="center" on the Modal's ZStack
was interfering with the ChatMessageActions positioning, causing the menu
to not reposition correctly, the blur overlay to not display, and touch
events to be blocked.

Fixes TLON-5490

* fix: bump react-qr-code to 2.0.18 for React 19 compat

v2.0.12 used defaultProps which React 19 no longer supports on
function components, causing "bad rs block" crash when rendering
the QR code on the personal invite sheet.

* fix: remove swipe direction guards that broke swipe actions in RNGH 2.28

In RNGH 2.20, onSwipeableWillOpen reported which side was opening (e.g.
'right' when right actions open). In RNGH 2.28, it reports the gesture
direction ('left' when swiping left to reveal right actions). This caused
the direction guard to hide the very actions being revealed — the right
actions returned <View /> because currentSwipeDirection was 'left'.

The guards are unnecessary since ReanimatedSwipeable already handles
showing/hiding the correct side internally via opacity animations.

Fixes TLON-5491

* fix: patch react-native TextInput measurement for uncontrolled inputs

In Fabric, measureContent() is called by Yoga during YGNodeCalculateLayout
before updateStateIfNeeded() runs. For uncontrolled TextInputs (no value
prop), attributedStringBoxToMeasure() returns the stale native text from
attributedStringBox even when React children have changed, causing the
TextInput to not resize (e.g. staying expanded after clearing text).

The patch checks if the React tree attributed string has diverged from
state for uncontrolled inputs and uses it for measurement instead.

Upstream PR: react/react-native#56291
Fixes TLON-5492

* fix: ignore bogus keyboard events when app is backgrounded

When the app is backgrounded, iOS fires keyboardWillChangeFrame with
screenY=0 and height=0. The component interpreted this as "keyboard
covers the entire screen" and applied massive paddingBottom, which
accumulated on each background/foreground cycle.

Fixes TLON-5493

* chore: regenerate .prettierignore

* fix: use clone-or-copy pnpm import method in e2e Dockerfile

The Tamagui v2 RC packages share esbuild as a dependency, causing
EEXIST collisions with --package-import-method=copy. Switch to
clone-or-copy which handles existing files gracefully.

* style: run prettier on files with formatting issues

* fix: remove pnpm store cache mount from e2e Dockerfile

The cache mount causes EEXIST hardlink collisions when multiple Tamagui
v2 packages share esbuild as a dependency. Without the cache mount, pnpm
uses its default import strategy which handles this correctly within a
single build layer.

* fix: use type-only import for db in modelBuilders to break circular dep

modelBuilders only uses db namespace for types (db.Post, db.Channel etc),
so import type is correct and breaks the import cycle that lint detected.

* fix: add @faker-js/faker devDependency to packages/app

The EditProfileScreen fixture in packages/app/fixtures/ imports
@faker-js/faker but it wasn't listed as a dependency of packages/app.

* fix: resolve lint errors from merge (missing Stack import, unused imports)

- AppInfoScreen: use View instead of Stack (already imported)
- MediaViewerScreen: remove unused Stack import
- MessageActions: remove unused api import

* Revert "fix: add @faker-js/faker devDependency to packages/app"

This reverts commit fde0b90.

* fix: correct import paths for StorageCredentials and StorageService

These types are exported from @tloncorp/api/urbit/storage, not
@tloncorp/api/client/upload.

* fix: resolve tsc errors in GestureMediaViewer and GestureTrigger

- useRef<string>() requires initial value in React 19 types - pass undefined
- GestureTrigger children type needs WithOnPress constraint to match library

* fix: patch @tamagui/web to restore ZStack child wrapping

In Tamagui v2, createComponent no longer calls spacedChildren() with
isZStack, so children are rendered in normal column flow instead of
being wrapped in AbsoluteFill containers. This patch restores the
wrapping behavior by checking staticConfig.isZStack and wrapping each
child in a position:absolute container, matching v1's behavior.

This fixes broken layouts in splash sequence, missing buttons in
sheets, gallery images not showing, and other ZStack-dependent UIs.

Fixes TLON-5564, TLON-5569, TLON-5574

* Revert "fix: patch @tamagui/web to restore ZStack child wrapping"

This reverts commit 070a16a.

* Reapply "fix: patch @tamagui/web to restore ZStack child wrapping"

This reverts commit eb8e676.

* Revert "Reapply "fix: patch @tamagui/web to restore ZStack child wrapping""

This reverts commit a1c75da.

* fix: resolve tsc errors in packages/app

- MediaViewerScreen: animation -> transition (Tamagui v2 prop rename)
- MessageInput: useRef<JSONContent>(undefined) for React 19 compat
- PostScreenView: useRef<setTimeout>(undefined) for React 19 compat

* fix: use ReturnType<typeof setTimeout> for timer ref type

NodeJS.Timeout is not assignable from setTimeout's return type in
browser environments. ReturnType<typeof setTimeout> works in both.

* fix: force copy import method in e2e Dockerfile

pnpm defaults to hardlinks in Docker overlayfs, causing EEXIST when
multiple Tamagui packages share esbuild. Force copy mode via config flag.

* fix: dedupe esbuild via override to prevent EEXIST in Docker

@tamagui/build and esbuild-plugin-es5 both depend on esbuild@0.27.3
but pnpm nested them separately, causing hardlink collisions in Docker.
Adding an esbuild override forces a single hoisted copy.

Also reverts the copy import-method workaround since the root cause
(duplicate esbuild) is now fixed.

* fix: add .web.* extensions to Vite resolve config

The reactNativeWebPlugin only set resolveExtensions for esbuild (dev
mode optimizer), not for Rollup production builds. This caused file
imports like `../../transcription` to resolve to the native .ts file
instead of the .web.ts stub, pulling in expo-speech-recognition which
crashes in the browser.

Adding the full extension list to resolve.extensions ensures .web.*
files are preferred in both dev and production builds.

* fix: fix production build for smoke tests

- Add resolve.extensions with .web.* entries so Rollup production builds
  correctly resolve platform-specific modules (e.g. transcription.web.ts)
- Move expo-polyfill import to top of main.tsx entry point
- Add preview.proxy config so vite preview proxies API requests to the
  ship (required for production smoke tests)

* style: format main.tsx with prettier

* fix: correct preview proxy path prefix for auth and API routes

The proxy pattern needs a leading slash to match request paths
(e.g. /~/login, /~/scry). Also add /spider/ proxy for thread requests.

* fix: fix vite preview proxy and disable tamagui config watcher

- Fix proxy path to use /~/ prefix (matches /~/login, /~/scry etc)
- Add /apps/groups/~/ proxy with path rewrite for base-prefixed requests
- Add /spider/ proxy for thread API requests
- Disable tamagui config watcher to prevent hanging on reanimated v4
  module resolution errors during vite preview

* fix: add /apps/landscape proxy for auth redirect in vite preview

After login, the ship redirects to /apps/landscape/ which vite preview
doesn't serve. Proxy it to the ship so auth setup completes.

* revert: remove preview proxy and disableWatchTamaguiConfig

These weren't needed before the Tamagui version change. Keep only the
resolve.extensions fix which is genuinely needed for .web.ts resolution.

* fix expo 54 regressions (#5711)

* fix zstack on native

* upgrade cosmos to 7.2.0

* fix tamagui unset handling

* fix rebase followups

* revert main.tsx reorder

* chore: fix prettier formatting

* chore: pin web import order

* fix prod smoke login path

* fix: add /apps/landscape proxy for vite preview

The production smoke test uses vite preview which doesn't use the dev
server proxy. After login via /apps/groups/~/login, the ship redirects
to /apps/landscape/ — vite preview can't serve that path (only serves
from /apps/groups/ base). Proxy it to the ship so auth setup completes.

* fix: proxy /apps/groups/~/ to ship in vite preview

Vite preview serves /apps/groups/~/login as an SPA route (returning
index.html) instead of the ship's login page, so auth setup fails.
Proxy the /apps/groups/~/ prefix to the ship with path rewrite to
strip the /apps/groups base.

* fix: proxy bare /~/ paths in vite preview

The Urbit login form posts to /~/login (no base prefix), and scries
use /~/ paths directly. Without a bare /~/ proxy, vite preview serves
its "did you mean /apps/groups/~/login" page and auth fails.

* fix: proxy bare / to ship in vite preview for login redirect chain

After login, the ship returns 303 with Location: /, and the ship's /
handler then redirects authenticated users to /apps/landscape/. Without
proxying /, vite preview intercepts and redirects to /apps/groups/,
breaking the auth flow. The ^/$ regex proxy matches only the exact
root path, leaving /apps/groups/ etc. untouched.

* fix: simplify vite preview proxy to match urbit plugin dev server

Replace the multiple preview.proxy entries with a single regex that
matches everything not under /apps/groups/ — mirroring the regex the
urbit plugin uses for server.proxy. This ensures all ship-bound paths
(/~/login, /~/channel, /, /apps/landscape, etc) are forwarded and
only static assets under the app base are served by vite.

Also revert the auth.setup.ts USE_PRODUCTION_BUILD branch from PR #5711
back to hitting /~/login directly — which is now proxied correctly.

* fix: proxy /apps/groups/desk.js in vite preview

The urbit plugin injects <script src="/apps/groups/desk.js"> into
index.html, which needs to be proxied to the ship. Vite preview
otherwise returns 404 since desk.js doesn't exist in the build.
Mirror the two-entry proxy config that urbitPlugin sets for the
dev server's server.proxy.

* fix: move expo-polyfill import to the very first line in main.tsx

The polyfill needs to run before any expo-modules-core consumer.
ES module imports execute their full dependency graph in source
order, so putting the polyfill import at the very top ensures its
side effect (setting globalThis.expo) runs before any subsequent
import's transitive dependencies try to access globalThis.expo.

* chore: remove redundant preview.proxy from vite config

vite preview actually uses server.proxy from the urbit plugin already,
so the explicit preview.proxy I added was a no-op. Verified locally
that without preview.proxy: /apps/groups/ → vite, /~/login → proxy,
/apps/groups/desk.js → proxy, /apps/landscape/ → proxy, / → proxy.

The earlier smoke test failures were from PR #5711's auth.setup.ts
using /apps/groups/~/login (which is correctly NOT proxied) plus the
expo-polyfill ordering issue. Both are fixed independently.

* fix: pop to existing tab screens on NavBar taps

* fix: tighten ESLint selector to require pop:true in third argument (suggested by copilot review)

* fix: use direct navigate instead of getParent() in GroupSettingsStack (Codex review feedback)

* cleanup: remove dead navigateToHome prop from GroupMetaScreen

* fix: drop ContentContext subscription on ContentImage (TLON-5570)

The Expo 54 upgrade pulled in a newer Tamagui rc whose styled-component
machinery installs an RNGH-backed press gesture on any styled view that
receives a press-shaped prop (onPress, onLongPress, onPressIn, onPressOut).
The gesture participates in Tamagui's shared global press-ownership state:
on every touch the innermost gesture overwrites the owner, and only the
owner's onPress fires.

ContentImage subscribed to ContentContext, which carries onLongPress.
Tamagui's context merging applied that field to the styled image as a
prop — silently making every rendered image a press boundary. The image
has no onPress handler, so on release it stole ownership from the
surrounding Pressable and then dropped the tap on the floor. Long-press
still worked because the image actually had an onLongPress. Net effect:
tapping a post image no longer opened the lightbox.

ContentImage doesn't need context: the consumers that care (ImageBlock,
LineText, BlockWrapper) read ContentContext via useContentContext() or
their own variants. Dropping the subscription keeps the image free of
press props and restores tap arbitration.

* chore(mobile): bump @shopify/flash-list to 2.3.1

* fix(ios): drop lineHeight on single-line TextInput

iOS (new architecture) anchors glyphs to the bottom of the line box
when lineHeight > fontSize on a single-line UITextField, pushing typed
text below the centered placeholder. $label/xl (fontSize 17,
lineHeight 24) trips this on every RawTextInput after the Expo 54 /
RN 0.81 upgrade.

Single-line inputs sit in a fixed-height centered container, so
lineHeight has no effect on vertical placement. Strip it from the base
style and restore it on TextInputComponent for multiline cases, where
line-to-line spacing still matters.

* fix(ios): patch expo-image-picker to download iCloud videos

Backport expo/expo#40697 onto expo-image-picker 17.0.10 (SDK 54). Adds
a shouldDownloadFromNetwork option that sets
PHAssetResourceRequestOptions.isNetworkAccessAllowed = true when
streaming a video resource via PHAssetResourceManager. Without it,
picking a video that has been offloaded to iCloud fails on iOS 26 with
"The operation couldn't be completed. (PHPhotosErrorDomain error 3164)".

AttachmentSheet opts in by passing shouldDownloadFromNetwork: true to
launchImageLibraryAsync. videoExportPreset stays at its native default
(Passthrough), which is what the option requires to take effect.

The upstream fix ships in expo-image-picker 55.0.0 (SDK 55); no SDK 54
backport was released, so we carry this as a pnpm patch until we
upgrade.

* fix(mobile): Poor UX report modal buttons overflow card

Tamagui TextArea with multiline expands beyond minHeight to fill
available flex space, pushing the button row past the card's
visible bottom edge. Set an explicit height and flexShrink: 0
to keep the textarea fixed at 120px.

* fix(mobile): use numberOfLines instead of minHeight for Poor UX modal

Tamagui v1 appears to not propagate minHeight to the native RN
TextInput (or it was clamped by the v1 wrapper's default
numberOfLines: 4), so the modal stayed compact and the buttons
never overflowed. On v2 (via RN 0.81's rows -> numberOfLines
mapping) the input can end up larger than the card expects,
pushing the button row outside the modal's rounded bottom.

Using numberOfLines={5} gives a deterministic intrinsic height
(~120px at lineHeight 24) that renders correctly in both versions
and doesn't conflict with the variant-computed height.

* Expo 54: fix nav issue in group settings

* fix(mobile): normalize path to URI in expo-file-system helpers

expo-file-system V2 in Expo 54 requires an absolute URI for `new File(...)`
and throws `IllegalArgumentException: URI is not absolute` when given a raw
path. On Android the audio recorder returns a raw path, so `getMimeType`
threw during voice message send — swallowed by an unhandled async rejection
and surfacing only as "button reacts but nothing happens".

Normalize path-or-URI inputs inside `getMimeType` and `getFileSize`.

* fix(mobile): normalize voice memo path at the call site

Localize the path→URI normalization to the voice-memo submit path rather
than hardening the shared `getMimeType`/`getFileSize` helpers. The audio
recorder is the only source that returns a raw path; other callers already
pass URIs.

* fix(mobile): render notification bell unread dot as circle on Android

Tamagui's `Circle` uses `borderRadius: 100_000_000`, which fails to
render as a circle on Android with RN 0.81 / new architecture at small
sizes. Replace the unread indicator in `NavIcon` with a plain `View`
using explicit width, height, and borderRadius so the dot renders
correctly on both platforms.

* fix(mobile): force remount of nav unread dot on hasUnreads change

The previous change replaced Circle with a View but did not address the
actual root cause. On a fresh app launch, the dot still rendered as a
square because react/react-native#52415 causes an Android new-arch
View to lose its border-radius clipping when its backgroundColor
transitions from transparent to opaque. Hot reload hid the bug by
re-mounting the view.

Restore the Circle and key it on `hasUnreads` so each state change
remounts the dot with a fresh, already-opaque background, which
renders a circle correctly on both platforms.

* fix(mobile): work around RN TextInput transparent color bug on Android

The OTP input overlays a hidden TextInput on top of the visual digit
cells. RN 0.81 (Expo SDK 54) regressed handling of `color: 'transparent'`
on Android, causing the underlying glyphs to paint on top of the cell
overlay and appear as duplicated/ghosted digits.

Using `#ffffff00` instead of the `'transparent'` keyword avoids the
regression until the upstream fix ships.

Refs:
- react/react-native#53343
- react/react-native#55380

* chore(deps): bump tamagui rc.22 -> rc.41

Drops the two tamagui patches that applied to rc.22:

- @tamagui/sheet: the patch supplied no-op defaults for
  setHasScrollView / scrollBridge.onParentDragging so the SheetHandle
  effect didn't crash when useSheetContext returned the default
  object. rc.41 guards the call with `if (!context.scrollBridge)
  return`, so the patch is no longer needed.

- @tamagui/web: the patch injected unset.fontFamily from the default
  font inside createTamagui. The configIn.unset concept has been
  removed from @tamagui/web in rc.41, so the patch targets code that
  no longer exists.

Also updates pnpm-lock.yaml to drop the now-unreferenced rc.22
tamagui entries; only rc.41 remains for both the direct and
transitive dependencies.

* fix group avatar recycling

* Fix iOS channel sorting with duplicate nav entries

* Fix duplicate channel nav memberships via forced sync

* e2e: fix group-channel-management to expect new channels at index 0 (backend prepends to zone.order)

* Wrap addChannelToNavSection sync handler in a transaction

* fix(bottom-sheet): patch gorhom to avoid flex overflow on first open

See patches/README.md.

* Add consistent POST_HOG_IN_DEV and default to false

- `envVars.native.ts` did not have a binding for POST_HOG_IN_DEV - added
  that and use it instead of directly accessing the string
  `process.env.POST_HOG_IN_DEV`
- `Boolean(process.env.POST_HOG_IN_DEV)` would evaluate to true with
  `POST_HOG_IN_DEV=false` - changed to check `=== 'true'`

* Fix omitting PostHog key in development mode

* fix: restore patches

* fix(ios): always forward didReceiveRemoteNotification to ExpoAppDelegate

The Swift port of AppDelegate guarded the super call with
`super.responds(to: #selector(...))`. That check is unreliable when
the superclass is a Swift class — `ExpoAppDelegate` (SDK 54) is
declared as a pure Swift `open class` and its UIApplicationDelegate
methods are not guaranteed to be exposed to the Obj-C runtime via the
selector lookup that `responds(to:)` performs. When the check returns
false we silently call `completionHandler(.noData)` and drop the
notification before expo-notifications, react-native-firebase, etc.
can see it.

`ExpoAppDelegate` does implement
application(_:didReceiveRemoteNotification:fetchCompletionHandler:)
(node_modules/expo/ios/AppDelegates/ExpoAppDelegate.swift), so calling
super unconditionally is safe and restores the dispatch chain. This
mainly affects silent `content-available` pushes — the notify-provider
uses these to wake the Notification Service Extension to fetch and
render rich content.

* fix(reactions): make reaction click work with Tamagui rc.41

Tamagui v2 rc.41 added e.stopPropagation() in createComponent's onPress
handler when the View has any onPress/onClick. The reaction button wrapped
its emoji content in <Pressable onPress={toggleReaction}> > <Tooltip.Trigger>,
where Tooltip.Trigger renders its own View with onPress. The inner Trigger's
stopPropagation blocked clicks from reaching the outer Pressable, so clicking
a reaction never toggled it on web.

Move onPress/onLongPress directly to Tooltip.Trigger (which uses
composeEventHandlers, and Tooltip's onOpenToggle is a no-op so click doesn't
open the tooltip). Drop the now-redundant outer Pressable wrapper.

* style: prettier fix on ReactionsDisplay

* Forward hoverStyle to Pressable instead of ListStyle/XStack

* test: add tamagui-ignore to ManageChannelsShared

Tamagui v2 rc.41 babel plugin emits duplicate testID attributes when the
testID is a template literal — and on web the second one is left as raw
testID rather than being converted to data-testid, so the rendered DOM
has neither. Disabling Tamagui compile-time optimization for this file
restores standard testID->data-testid handling via react-native-web at
runtime, which the e2e tests rely on.

Channel management e2e tests (group-channel-management, group-cross-ship-
visibility) currently can't find ChannelItem-* testIDs in the rendered
DOM as a result.

* fix(reactions): drop onLongPress from Tooltip.Trigger to avoid spurious sheet

Tamagui v2's web onPress wrapper unconditionally calls onLongPress on every
click (createComponent.mjs:762-768 — `onPress?.(e); onLongPress?.(e);` with
no gating). Putting onLongPress on Tooltip.Trigger therefore opened the
ViewReactionsSheet on every reaction toggle, causing a stray <span>👍</span>
in the sheet's tab/list to live alongside the reaction in the DOM and break
strict-mode locator queries (e2e: chat-core-functionality removeReaction).

The original code didn't hit this because @tloncorp/ui's Pressable strips
onLongPress on web (Pressable.tsx: `const longPressHandler = isWeb ? undefined
: onLongPress;`). Tooltip.Trigger renders a tamagui View directly, with no
such filtering.

Drop onLongPress from Tooltip.Trigger; the outer per-row Pressable still
handles long-press on native, and long-press is irrelevant on web anyway.

* patch(@tamagui/static): drop spurious style key after createDOMProps

Tamagui v2 rc.41's createExtractor passes single non-style props (like
`testID`) through `react-native-web-internals.createDOMProps` to get the
testID -> data-testid rewrite, but createDOMProps unconditionally sets
`domProps.style = stylesAtomic.reduce(...)` (i.e., always emits a `style`
key, even an empty {} when no style was provided). The extractor only
strips `out.className`, leaving `out.style` behind.

Downstream `Object.keys(out).map(...)` then iterates two keys (`style` and
`data-testid`/the original prop) instead of one. Both branches return the
same original `attr` reference, so the same JSXAttribute ends up duplicated
in the emitted JSX. For static-string testIDs the duplicate is harmless
(both get rewritten to `data-testid`); for template-literal testIDs the
rewrite is skipped and the DOM ends up with `<div testID="..." testID="...">`,
which React drops entirely — leaving no `data-testid` and breaking
Playwright's getByTestId.

Drop the spurious `style` key after `createDOMProps` in both code paths
(the single-prop path at line 855 and the variant-expansion path at line
1485). With this patch, the channel-management e2e tests no longer need
the `// tamagui-ignore` workaround on ManageChannelsShared.

* fix unread dot shape (#5816)

* patch(@tamagui/static): rewrite renamed prop name when value is dynamic

The previous patch dropped the spurious `style` key from createDOMProps so
the same `attr` reference wasn't emitted twice for a single non-style prop,
fixing the duplicate-attribute case. But static-string testIDs hid a second
bug: when createDOMProps renames a prop (e.g. testID -> data-testid), the
extractor only relied on a later `case "attr"` pass that runs `getProps`
on the value to discover the new key — and that pass is gated on
`attemptEvalSafe(value) !== FAILED_EVAL`, so dynamic values stayed as raw
`testID={template}` in the emitted DOM. React then drops the unknown HTML
attribute, so `getByTestId` (and any other downstream consumer) never
finds it.

Now: in the data/aria/HTML-attribute branch of `Object.keys(out).map`,
when the createDOMProps output key differs from the original JSX prop
name, build a fresh JSXAttribute with the new name and the original
expression value. This handles both static and dynamic values uniformly
and unblocks template-literal `testID`s without falling back to
`// tamagui-ignore`.

* patch(@tamagui/static): scope rename to dynamic values only

Previous patch revision renamed unconditionally when createDOMProps
produced a different output key. That caught testID -> data-testid for
both static and dynamic values, but it also rewrote `focusable={true}`
to `tabIndex` for static values — which v2 specifically chose NOT to do
(see tamagui's webAlignment "focusable is NOT converted to tabIndex in v2"
test).

Restrict the inline rename to FAILED_EVAL (dynamic) values. Static values
continue to flow through the later `case "attr"` rename pass, which uses
getProps() and respects v2's hand-picked keep-original-name behavior for
props like focusable.

* restore preview share extension target (#5818)

* fix(posthog): gate side effects on posthogEnabled, not posthog truthiness

The merge from develop combined two refactors: the singleton extraction
(posthog kept as PostHog | undefined) and the always-defined client with
disabled flag. The resulting if (posthog) check is now always truthy, so
side effects intended to be skipped when PostHog is disabled - including
writing the API key to UserDefaults so iOS native captures fire - were
running in test and dev-without-POST_HOG_IN_DEV.

Switch the gate to posthogEnabled and drop the unreachable !posthog
early-returns now that posthog is always defined.

* chore(flash-list): remove dead size measurement helpers

FlashList v2's overrideItemLayout type is { span?: number } - the size
field present in v1 was dropped. With estimatedItemSize gone too, the
sizeRefs / handleHeaderLayout / handleItemLayout / handleOverrideLayout
machinery in these four lists no longer affects rendering: the sizes
are written but never read by the layout manager.

Drop the dead refs, callbacks, onLayout wiring on rendered items, and
the overrideItemLayout prop. ForwardChannelSelector keeps ITEM_H since
drawDistance still uses it.

* chore(vite): drop duplicate resolve.extensions in favor of plugin-supplied

reactNativeWebPlugin already declares the same extensions array via its
config() hook. A local prod build confirms transcription.web.ts is
preferred over transcription.ts (no expo-speech-recognition in the
output bundle), so the explicit declaration in vite.config was
redundant. Single source of truth in the plugin.

If CI smoke tests show platform-specific files no longer resolve in
prod, revert to the inverse - extensions only in vite.config and dropped
from the plugin.

* chore(vite): drop dead resolveId hook from reactNativeWebPlugin

A diff of prod bundle hashes with vs without this hook is byte-identical
on the current branch. Tracing it with logs showed 8 hits during build
(react-native-reanimated's ./ReanimatedModule, expo-modules-core's
./polyfill and ./uuid), but the resolutions don't change the output -
Rollup tree-shakes the resolved exports either way because nothing in
the app consumes them directly. The polyfill case is handled by the
explicit src/expo-polyfill.ts import; file-level platform resolution
(transcription.web.ts etc.) is handled by resolve.extensions.

If CI smoke tests reveal a path I didn't exercise (dev HMR or other
modes), revert is one commit.

* Revert "chore(vite): drop dead resolveId hook from reactNativeWebPlugin"

This reverts commit 6a07ebd.

---------

Co-authored-by: Dan Brewster <dnbrwstr@gmail.com>
Co-authored-by: Patrick O'Sullivan <patrick@osullivan.io>
Co-authored-by: bᵣᵢₐₙ <90741358+latter-bolden@users.noreply.github.com>
Co-authored-by: David <david.isaac.lee@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Contributor A React Native contributor. Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants