From 837afa4bb3cc380224420c9f421af8a049c77765 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 17 Aug 2026 12:45:34 -0400 Subject: [PATCH 01/11] chore(deps): routine dependency update Bump @legendapp/list, expo + expo-asset/image-picker/location/task-manager, and immer. Regenerate the expo hmr patch for 57.0.14 (content unchanged). Add a **/deepmerge-ts resolution for GHSA-ggr8-5vv4-36mx: every installed copy was 7.1.5 and the @wdio/* parents are already current, so no released parent picks up the patched 8.x on its own. Held back deliberately: @babel/* 8.x, react-native 0.87, typescript 7 (typescript-eslint and analyze-styles still need the classic compiler API), and react-native-worklets 0.12 (no matching reanimated release). --- shared/ios/Podfile.lock | 28 ++-- shared/package.json | 15 +- ...{expo+57.0.13.patch => expo+57.0.14.patch} | 0 shared/yarn.lock | 146 ++++++++++-------- 4 files changed, 103 insertions(+), 86 deletions(-) rename shared/patches/{expo+57.0.13.patch => expo+57.0.14.patch} (100%) diff --git a/shared/ios/Podfile.lock b/shared/ios/Podfile.lock index 841aa0feb7e1..a6e60b8201c2 100644 --- a/shared/ios/Podfile.lock +++ b/shared/ios/Podfile.lock @@ -1,7 +1,7 @@ PODS: - - EXConstants (57.0.11): + - EXConstants (57.0.12): - ExpoModulesCore - - Expo (57.0.13): + - Expo (57.0.14): - ExpoModulesCore - ExpoModulesJSI - hermes-engine @@ -27,7 +27,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - ExpoAsset (57.0.11): + - ExpoAsset (57.0.12): - ExpoModulesCore - ExpoAudio (57.0.3): - ExpoModulesCore @@ -58,15 +58,15 @@ PODS: - SDWebImageAVIFCoder (~> 0.11.0) - SDWebImageSVGCoder (~> 1.7.0) - SDWebImageWebPCoder (~> 0.14.6) - - ExpoImagePicker (57.0.10): + - ExpoImagePicker (57.0.11): - ExpoModulesCore - ExpoKeepAwake (57.0.1): - ExpoModulesCore - ExpoLocalization (57.0.1): - ExpoModulesCore - - ExpoLocation (57.0.10): + - ExpoLocation (57.0.11): - ExpoModulesCore - - ExpoLogBox (57.0.2): + - ExpoLogBox (57.0.3): - React-Core - ExpoMailComposer (57.0.1): - ExpoModulesCore @@ -112,7 +112,7 @@ PODS: - ExpoModulesCore - ExpoSMS (57.0.1): - ExpoModulesCore - - ExpoTaskManager (57.0.10): + - ExpoTaskManager (57.0.11): - ExpoModulesCore - UMAppLoader - ExpoVideo (57.0.2): @@ -2891,9 +2891,9 @@ EXTERNAL SOURCES: :path: "../node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: - EXConstants: 13cc6ad9324d81223386074a523d4e16aed32cd2 - Expo: 02ec524ae0eda32b052decb8ea9b6c2f1c85e456 - ExpoAsset: e046c820a585f46969182c2aa03c3a822dd55b6b + EXConstants: 092a945e490d473e0b6aec724684b36c447dd62a + Expo: 0a22949b8fd3e8e544aa8bd5a75cb36f932289ea + ExpoAsset: 1a913b91ccf6db8a5f68ba0c3ab75b8683766cf1 ExpoAudio: b69f36525132edba8d87a715b3a20c678ef78b30 ExpoCamera: a5e9d9ce2fc06689eb2dd676f1f8d5f22f8aa292 ExpoCameraBarcodeScanning: b869ac87ebe30bb99b26c55b0e0471301383c90b @@ -2905,11 +2905,11 @@ SPEC CHECKSUMS: ExpoFont: 59e1faf66ba9bcd232ae1e2ce58d202a48b6f65e ExpoHaptics: 4503d2da51ff7109712ccdfe0e4939f66818183e ExpoImage: b37accc4b12647e5cf939b2335eea2bdb874ad7f - ExpoImagePicker: 945c7f41749d99266d220a67118ae769b4d2af19 + ExpoImagePicker: b6f0ce415819462b7cf28c47e7a9e147624ef29e ExpoKeepAwake: c26f14275017370cc8a4b7b43a0e23361f2053a5 ExpoLocalization: 659243b03b3a3e9589793cee2f728a899e181329 - ExpoLocation: 1da1cdf04d24886ca57a98fa36704fa3d5e23629 - ExpoLogBox: 6bb73c341aca22e699bd4da6cc61afc844e1a648 + ExpoLocation: 30cc8a3337823bf3725a332a96c07ce1d1521875 + ExpoLogBox: 2c4225a62f1cfb943e6b2df4fa4fe2d136e06fb6 ExpoMailComposer: 101933dc6bdb4d7a46495d1dd87420365325adb9 ExpoMediaLibrary: 344f5b528a71f34794e9d7396e32589531df146e ExpoModulesCore: 3b0be7a40e69428089fe7ac7054586a20778d276 @@ -2918,7 +2918,7 @@ SPEC CHECKSUMS: ExpoModulesWorkletsAdapter: 4825908c915e61b9e4408c893a66307221269c8f ExpoScreenCapture: 6b82aba55db3ecc6f124ebd45163d6db9b916b2d ExpoSMS: ac35f6c85b72ee5b18a7c6bb06550fbd4a683775 - ExpoTaskManager: 83460ba590ec911d8eb66c38a49de817b127e0e8 + ExpoTaskManager: 764fd9262a9db7a194b58eba220035a7fbd825e8 ExpoVideo: b1d9a325ddcd4f3ed8ccb374c01b688e98bc6a04 FBLazyVector: 3c3be9a019176b5699455f7f66c5444b78a411c6 hermes-engine: 034fad1f720e9fb9fe1b5ae5ac0c217531216c50 diff --git a/shared/package.json b/shared/package.json index eba3b73efdff..372a848dab54 100644 --- a/shared/package.json +++ b/shared/package.json @@ -92,7 +92,7 @@ "@gorhom/bottom-sheet": "5.2.14", "@gorhom/portal": "1.0.14", "@khanacademy/simple-markdown": "3.0.1", - "@legendapp/list": "3.3.5", + "@legendapp/list": "3.3.6", "@msgpack/msgpack": "3.1.3", "@react-native-community/netinfo": "12.0.1", "@react-native-masked-view/masked-view": "0.3.2", @@ -104,8 +104,8 @@ "date-fns": "4.4.0", "emoji-datasource-apple": "16.0.0", "emoji-regex": "10.6.0", - "expo": "57.0.13", - "expo-asset": "57.0.11", + "expo": "57.0.14", + "expo-asset": "57.0.12", "expo-audio": "57.0.3", "expo-camera": "57.0.3", "expo-clipboard": "57.0.1", @@ -114,17 +114,17 @@ "expo-file-system": "57.0.4", "expo-haptics": "57.0.1", "expo-image": "57.0.3", - "expo-image-picker": "57.0.10", + "expo-image-picker": "57.0.11", "expo-localization": "57.0.1", - "expo-location": "57.0.10", + "expo-location": "57.0.11", "expo-mail-composer": "57.0.1", "expo-media-library": "57.0.4", "expo-screen-capture": "57.0.2", "expo-sms": "57.0.1", - "expo-task-manager": "57.0.10", + "expo-task-manager": "57.0.11", "expo-video": "57.0.2", "google-libphonenumber": "3.2.46", - "immer": "11.1.16", + "immer": "11.1.17", "lodash": "4.18.1", "lottie-react-native": "7.4.0", "lottie-web": "5.13.0", @@ -204,6 +204,7 @@ }, "resolutions": { "**/@types/react": "19.2.18", + "**/deepmerge-ts": "8.0.1", "**/serialize-javascript": "7.1.0", "**/xcode/uuid": "14.0.1" }, diff --git a/shared/patches/expo+57.0.13.patch b/shared/patches/expo+57.0.14.patch similarity index 100% rename from shared/patches/expo+57.0.13.patch rename to shared/patches/expo+57.0.14.patch diff --git a/shared/yarn.lock b/shared/yarn.lock index 77fff162f9e5..0ea622f5131c 100644 --- a/shared/yarn.lock +++ b/shared/yarn.lock @@ -1568,20 +1568,20 @@ "@eslint/core" "^1.2.1" levn "^0.4.1" -"@expo/cli@^57.0.15": - version "57.0.15" - resolved "https://registry.yarnpkg.com/@expo/cli/-/cli-57.0.15.tgz#8387c9018feeef82bdd22b8e57e4e0326103dbe1" - integrity sha512-YIE5xgZlkwx6OyjjVq/zAfqtYvczsWLEkOukDJwaZDaMQl8x5/CjoqC/Oe670DFQc5xjqguH6cmZUWHHL7yH3g== +"@expo/cli@^57.0.16": + version "57.0.16" + resolved "https://registry.yarnpkg.com/@expo/cli/-/cli-57.0.16.tgz#751579acf585ec078a31c65a26600822e91d05a2" + integrity sha512-+HyMY2nAS6QBJb0nSeMz92p11bZ1AtWSRnhWnklznN07IRoQirisnKq2vr18Ayjb/fnb5F/um+n6FRhF0fZm1Q== dependencies: "@expo/code-signing-certificates" "^0.0.6" - "@expo/config" "~57.0.7" + "@expo/config" "~57.0.8" "@expo/config-plugins" "~57.0.8" "@expo/devcert" "^1.2.1" "@expo/env" "~2.4.2" "@expo/image-utils" "^0.11.4" - "@expo/inline-modules" "^0.1.5" + "@expo/inline-modules" "^0.1.6" "@expo/json-file" "^11.0.1" - "@expo/log-box" "^57.0.2" + "@expo/log-box" "^57.0.3" "@expo/metro" "~56.0.0" "@expo/metro-config" "~57.0.8" "@expo/metro-file-map" "^57.0.1" @@ -1699,6 +1699,22 @@ semver "^7.6.0" slugify "^1.3.4" +"@expo/config@~57.0.8": + version "57.0.8" + resolved "https://registry.yarnpkg.com/@expo/config/-/config-57.0.8.tgz#3a75f70d436e3e9e48140e8008fdac1d4fe44d3b" + integrity sha512-7VpAu2ZMXNZI0+Kn+nD3vQBIyST89LATNkZpnp/cXh8/aj7zUXO8d/T+hOxsvhOFwR8eQO+jKXEw8tQbidiMxA== + dependencies: + "@expo/config-plugins" "~57.0.8" + "@expo/config-types" "^57.0.2" + "@expo/json-file" "^11.0.1" + "@expo/require-utils" "^57.0.4" + deepmerge "^4.3.1" + getenv "^2.0.0" + glob "^13.0.0" + resolve-workspace-root "^2.0.0" + semver "^7.6.0" + slugify "^1.3.4" + "@expo/devcert@^1.2.1": version "1.2.1" resolved "https://registry.yarnpkg.com/@expo/devcert/-/devcert-1.2.1.tgz#1a687985bea1670866e54d5ba7c0ced963c354f4" @@ -1733,10 +1749,10 @@ resolved "https://registry.yarnpkg.com/@expo/expo-modules-macros-plugin/-/expo-modules-macros-plugin-0.6.1.tgz#5581bbe09e0aedb39ea787a9960eb0a9ee07d2ff" integrity sha512-cpsLZE4rqkc1Y3eZTkxB98jrqY1YXgetmtxFt8q89jBRmk3quRuk1BZo+VcnCSObZardjg99r1k5xijEMONFGA== -"@expo/fingerprint@^0.20.7": - version "0.20.7" - resolved "https://registry.yarnpkg.com/@expo/fingerprint/-/fingerprint-0.20.7.tgz#9fbf057dd113398445a4475ca839c713e7b5cf67" - integrity sha512-tYyZD4XZSn1C30pr9IvjN/BjAqpf6r9e1NL09lPvheO1DMLByOVdmMHFLSwW8Pu6veVgzue7GDWmq9P8mc8GMg== +"@expo/fingerprint@^0.20.8": + version "0.20.8" + resolved "https://registry.yarnpkg.com/@expo/fingerprint/-/fingerprint-0.20.8.tgz#a02741143749f417ad8fd663139b075a8d8d6a2d" + integrity sha512-0UOg9dVepR0LO4iUacoFuWZ9GNSUdUkdx9GX+Rj0mRpOTAtavyiem8IfewCAHij3cppqjBic134JD2lUxsZcJw== dependencies: "@expo/env" "^2.4.2" "@expo/spawn-async" "^1.8.0" @@ -1763,12 +1779,12 @@ parse-png "^2.1.0" semver "^7.6.0" -"@expo/inline-modules@^0.1.5": - version "0.1.5" - resolved "https://registry.yarnpkg.com/@expo/inline-modules/-/inline-modules-0.1.5.tgz#a9dfbd7c92ef9fd1986845aabe7c1a196e68555e" - integrity sha512-LC+kWeIwnvsGIvDaFBd8uzleWzWZiZTCG7CmtfxBLjW/Gify596X67ZqTi76iHzm5KToAfwwX0nppAKPDA9vQw== +"@expo/inline-modules@^0.1.6": + version "0.1.6" + resolved "https://registry.yarnpkg.com/@expo/inline-modules/-/inline-modules-0.1.6.tgz#9f7fb2adead2dfb8b4456e01a315a676b6e08332" + integrity sha512-5f6EiOIKsFj9zlrCBet4ZIQRPEa9dBUdQgTzpjYwdZ8Z/M8W5lqMVL9dEs4HYKvEmDnqv0dQuDkFLyRSwu/DAQ== dependencies: - "@expo/config-plugins" "~57.0.7" + "@expo/config-plugins" "~57.0.8" "@expo/json-file@^11.0.1", "@expo/json-file@~11.0.1": version "11.0.1" @@ -1786,10 +1802,10 @@ "@expo/config" "~57.0.7" chalk "^4.1.2" -"@expo/log-box@^57.0.2": - version "57.0.2" - resolved "https://registry.yarnpkg.com/@expo/log-box/-/log-box-57.0.2.tgz#2185dc3484ee81c63c3e7be5dc280d6300a15872" - integrity sha512-ZsFyfIR7YCbQAdVLzuTUmMHofZC7ZS9ywYCJNPlLc78x59cI8GwXFEIVbRjjC0uJERpNtXx/tsNNnkhexXlzMw== +"@expo/log-box@^57.0.3": + version "57.0.3" + resolved "https://registry.yarnpkg.com/@expo/log-box/-/log-box-57.0.3.tgz#89c81b30083b98c11d5d2cffba535a0fcab8d144" + integrity sha512-qv/cMliNax6es07Un/4IGJIcs4PUuhTKKTrJZX/0X2YRcOT5cqm/QicibZwTIbiZWi1i8P4YBRQTfFT2B17giw== dependencies: "@expo/dom-webview" "^57.0.1" anser "^1.4.9" @@ -2662,10 +2678,10 @@ dependencies: "@khanacademy/perseus-utils" "2.1.5" -"@legendapp/list@3.3.5": - version "3.3.5" - resolved "https://registry.yarnpkg.com/@legendapp/list/-/list-3.3.5.tgz#a2a7b8e0c3897fc5bd159d7b87e0b62ef45a4f74" - integrity sha512-XTsLYtpg41SVb5uLBYA+YcDSA3w0tgoPq/W8ZggQ2tx+3lrC/rf+ehTP9KYHea9oFaZIuePAgzACs5/auVMJlQ== +"@legendapp/list@3.3.6": + version "3.3.6" + resolved "https://registry.yarnpkg.com/@legendapp/list/-/list-3.3.6.tgz#38bc9dbd50d66c689afcda7766a34644e901f90b" + integrity sha512-XlDxqur/A65qURIHJy9VlNW9b7Bvb+m4cidcryOBqHRa8I0MYlLtptkWBysgcwrHLwo2+fVFyeDvy7nQytsIQg== dependencies: use-sync-external-store "^1.5.0" @@ -6227,10 +6243,10 @@ deep-is@^0.1.3: resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831" integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== -deepmerge-ts@^7.0.3: - version "7.1.5" - resolved "https://registry.yarnpkg.com/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz#ff818564007f5c150808d2b7b732cac83aa415ab" - integrity sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw== +deepmerge-ts@8.0.1, deepmerge-ts@^7.0.3: + version "8.0.1" + resolved "https://registry.yarnpkg.com/deepmerge-ts/-/deepmerge-ts-8.0.1.tgz#755f118cd798df500bfc0b0aad3861dd89cc904b" + integrity sha512-szCXE7YLCvLKR9bFPJcvsezOShdalctSvrgN/LM/QGUEPZQajwjmsMObZ6/DuANT5lxzM/wtO8Feubwdkz8myA== deepmerge@^4.3.0, deepmerge@^4.3.1: version "4.3.1" @@ -7113,13 +7129,13 @@ expect@30.4.1, expect@^30.0.0, expect@^30.4.1: jest-mock "30.4.1" jest-util "30.4.1" -expo-asset@57.0.11, expo-asset@~57.0.11: - version "57.0.11" - resolved "https://registry.yarnpkg.com/expo-asset/-/expo-asset-57.0.11.tgz#0ee7a64850bf22eef9e4d1ddd211af9b46be3df5" - integrity sha512-tDaExt9fOKeSc51mmZNqfejyBt9argzo14XP9OmKEdAk2E/mWKJnUNj6rfUzHC5MhG120xuZC/d1ncqq0aC7Pg== +expo-asset@57.0.12, expo-asset@~57.0.12: + version "57.0.12" + resolved "https://registry.yarnpkg.com/expo-asset/-/expo-asset-57.0.12.tgz#ab78e5d96b28ffccbe5ce4b8577662af68c2fb55" + integrity sha512-DbNxGsmqCaE2LWyabXEkDqV/8/mrZ2ikmVzi1V1Dp4MaQO9Kde6VcvQk7h6NVsklmo4xPT1J2Lnrs8Vd18sXOg== dependencies: "@expo/image-utils" "^0.11.4" - expo-constants "~57.0.11" + expo-constants "~57.0.12" expo-audio@57.0.3: version "57.0.3" @@ -7138,10 +7154,10 @@ expo-clipboard@57.0.1: resolved "https://registry.yarnpkg.com/expo-clipboard/-/expo-clipboard-57.0.1.tgz#d0550de548d313793d2240250081e9e453dbaf71" integrity sha512-HWICri4+1ao7S6QEfcorxVumXDiDnx1guGGewjZgGJWLGxFYs0RgH8ujBs+lkTzBkMmlwADaWSlaesR+nDJt5Q== -expo-constants@~57.0.11: - version "57.0.11" - resolved "https://registry.yarnpkg.com/expo-constants/-/expo-constants-57.0.11.tgz#7a51e8d588e267641e2390148f46fdb0f266f940" - integrity sha512-gpSZ13Nfx0BOc9ALjG1SMhks5csb8Z6ncEVdhDgv0NRrmJcHPRvyUlWaKjRjzwW730XrQ6PIXFRcnNmAxOLj5A== +expo-constants@~57.0.12: + version "57.0.12" + resolved "https://registry.yarnpkg.com/expo-constants/-/expo-constants-57.0.12.tgz#f9de7d1d89227cda2ff9b89a3e7aacdbaf83860c" + integrity sha512-js9qxZw4G9ulSc72GGpYGwtmk/IzFcf6/tTcEG6wB8HTlkunMS4W86R+4e6oHIrIkHA7W6s/79Se+E+DC3RAqA== dependencies: "@expo/env" "~2.4.2" @@ -7155,7 +7171,7 @@ expo-document-picker@57.0.1: resolved "https://registry.yarnpkg.com/expo-document-picker/-/expo-document-picker-57.0.1.tgz#c5a2bd3c431fcce1f3cf5b98661500f08d5b3285" integrity sha512-qBwM5oxDZ3I9kwFD3pUE1oK/WNv9artoEKO6UpqhQgNRr0XA1ALRVWYjkF4+ge9lUNDRehjTm/jenINkzqg84g== -expo-file-system@57.0.4, expo-file-system@~57.0.3: +expo-file-system@57.0.4, expo-file-system@~57.0.4: version "57.0.4" resolved "https://registry.yarnpkg.com/expo-file-system/-/expo-file-system-57.0.4.tgz#cc58cff9236820b6ee8da3dcb38dc980844e51b1" integrity sha512-h8qDOxSW1SQfnZyvH0+HeOfXCr2X4v3x50+d19JuyQcjDR+JA3tng20TlQxIHazzLPPbwtSKA1Dj3+XIOpnoKg== @@ -7177,10 +7193,10 @@ expo-image-loader@~57.0.1: resolved "https://registry.yarnpkg.com/expo-image-loader/-/expo-image-loader-57.0.1.tgz#b031dc3e62ac09004f3e436d7081487236791f53" integrity sha512-uhrZKLT/cTl2mXyR28kPpVkS5O+PK9N1QA/07IFM4f5T4g0lTW1JHT3NEWwEEsGFldPmVX4j7LwUVVZxE+woug== -expo-image-picker@57.0.10: - version "57.0.10" - resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-57.0.10.tgz#609c80d30d554d19547b2bfc9d783a6da33466c7" - integrity sha512-Zr00cd4RuUJlke8yN8/lKdl55UauoacF45j86zJa6oxJoe+wjlt9kkf0aGxQeoPW1JILT5Ikr3Os4+a9ROJYEw== +expo-image-picker@57.0.11: + version "57.0.11" + resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-57.0.11.tgz#4c9eab85bae9d73a6070350451d27899f38e2739" + integrity sha512-CyeQuGeA2Jei8zQb8S2fxmK6OIvdvUOYpxXegy8Dyc2WsI0GfWcgH8f+6uZmBaMlxakMPP4cfNvT1LeSMbB0PA== dependencies: expo-image-loader "~57.0.1" @@ -7203,10 +7219,10 @@ expo-localization@57.0.1: dependencies: rtl-detect "^1.0.2" -expo-location@57.0.10: - version "57.0.10" - resolved "https://registry.yarnpkg.com/expo-location/-/expo-location-57.0.10.tgz#11d324bdc7ac12d834a3b0f459ceb2c82464b31c" - integrity sha512-E/NdVxmd2+lODFgMc8CC6L0Zl+w20L1VEwPBGdJXblGew6Sv70FA3NlVP73Hi/QWmjECvbYTjkKR7OnrVnmaLA== +expo-location@57.0.11: + version "57.0.11" + resolved "https://registry.yarnpkg.com/expo-location/-/expo-location-57.0.11.tgz#8af48f59a4bc70eaa69b2cfb911b5c67fee86144" + integrity sha512-MwUroPjIWAnwac6aNHeGcqq9Il6/Mqrz36uVrbBi6MOjABcaeQK96WJo7+hbJP/CGZSa9enxxWpROgvH3m0DlQ== dependencies: "@expo/image-utils" "^0.11.4" @@ -7259,10 +7275,10 @@ expo-sms@57.0.1: resolved "https://registry.yarnpkg.com/expo-sms/-/expo-sms-57.0.1.tgz#64cf22ca1f5a3678bfa7b05a9b9314dec53c4696" integrity sha512-N5WGAA2Hw0nCGQIE8MAaL74ITwRhJdhtjIKryvExaGBxLXHZMgDD3kGdP4+feTo310kZBsGmXC5EtDqcrizE5Q== -expo-task-manager@57.0.10: - version "57.0.10" - resolved "https://registry.yarnpkg.com/expo-task-manager/-/expo-task-manager-57.0.10.tgz#27dc1958de11da62d7dc1cd6d41aa8797a47bdbb" - integrity sha512-K43+CdKXOvFteatTFzP9lOtpLq0Rzm7VWxIGTPe2Eue61Q/IBHdBuyK7ElKdsXAt0zUFeZDEuIVoLhJLMSwGYQ== +expo-task-manager@57.0.11: + version "57.0.11" + resolved "https://registry.yarnpkg.com/expo-task-manager/-/expo-task-manager-57.0.11.tgz#14c7a29847645a3b2274542c0fa9a272214420e9" + integrity sha512-d1W2RDoiGXqlfouDDQYWl3Mx6IuuZHnM5HS+Nd9CLZd3JFMJulZGTAGaBi0FyCt0LWKEEZCly8Z0JXTW6uIWDQ== dependencies: unimodules-app-loader "~57.0.1" @@ -7271,27 +7287,27 @@ expo-video@57.0.2: resolved "https://registry.yarnpkg.com/expo-video/-/expo-video-57.0.2.tgz#e54c5d17e48ce7b6fb87fba6f36310b54a3a9c97" integrity sha512-s8KDRpGNWOE7Vx7+1o89hGeXZ3evx00SrQ2+5odYcmvUikNvF59/VuFkg55+YIS981wVQz/07csK6hLAK/w4bQ== -expo@57.0.13: - version "57.0.13" - resolved "https://registry.yarnpkg.com/expo/-/expo-57.0.13.tgz#6b0d1338b9af54fe25aab3c634d2ceb684245b22" - integrity sha512-oJnPSAcQYDIXTJowL9fm6SvAU5CU0P+u1WtWM9a/7uUmeCrkcVKoaGstwF+UY3GoM11S7FO4KKmRl1aU12CTKA== +expo@57.0.14: + version "57.0.14" + resolved "https://registry.yarnpkg.com/expo/-/expo-57.0.14.tgz#b59764a64e7e4f850ee0b4de5bc99d24bf88c047" + integrity sha512-vJ7V/VNr2IGxhhBdRs1qDExPbEe+DvfqVGp3818gyfr4k0l7mdBb1QuDFH0VCX6wT9/18EYxW7SQca4tXkzDYw== dependencies: "@babel/runtime" "^7.20.0" - "@expo/cli" "^57.0.15" - "@expo/config" "~57.0.7" + "@expo/cli" "^57.0.16" + "@expo/config" "~57.0.8" "@expo/config-plugins" "~57.0.8" "@expo/devtools" "~57.0.1" "@expo/dom-webview" "~57.0.1" - "@expo/fingerprint" "^0.20.7" + "@expo/fingerprint" "^0.20.8" "@expo/local-build-cache-provider" "^57.0.6" - "@expo/log-box" "^57.0.2" + "@expo/log-box" "^57.0.3" "@expo/metro" "~56.0.0" "@expo/metro-config" "~57.0.8" "@ungap/structured-clone" "^1.3.0" babel-preset-expo "~57.0.7" - expo-asset "~57.0.11" - expo-constants "~57.0.11" - expo-file-system "~57.0.3" + expo-asset "~57.0.12" + expo-constants "~57.0.12" + expo-file-system "~57.0.4" expo-font "~57.0.1" expo-keep-awake "~57.0.1" expo-modules-autolinking "~57.0.10" @@ -8246,10 +8262,10 @@ immediate@~3.0.5: resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ== -immer@11.1.16: - version "11.1.16" - resolved "https://registry.yarnpkg.com/immer/-/immer-11.1.16.tgz#66702b60de57fda6a0b57eef52c84258cc4aac9b" - integrity sha512-Xs7H9rBc+kti1J6RueUvbEBkmOz7jqj11XYgf+YMXAYzu8EeE7hwZ9poLXdVfVnGmJu7QAf41T7H2KuF6QoK6Q== +immer@11.1.17: + version "11.1.17" + resolved "https://registry.yarnpkg.com/immer/-/immer-11.1.17.tgz#ba8a16e3b801b6dcb0fc753e043348a8ec42b696" + integrity sha512-8Vu44Y0MuMBlTQz/jQ8HEMYNq/bBqk87MnBwYR5mC8AthfhEXidZ5aT/oA/CUqboa8THKltnD9L3xyqhU/Sy1Q== import-fresh@^3.3.0: version "3.3.1" From fed616ede33d2ff57c9ed9891376e1a557798bd7 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 17 Aug 2026 13:16:07 -0400 Subject: [PATCH 02/11] fix(chat): keep the desktop thread pinned to the end when sending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit legend-list 3.3.6 rewired maintainScrollAtEnd from a raw DOM scrollToEnd to its own computed scroll, gated behind scrollRequestTracker.runNowIfIdle. On a send, the dataChange trigger fires before the new row is measured, so the end target it computes is the offset the list already holds. That request moves nothing, no scroll event arrives to settle it, and while it counts as in flight runNowIfIdle drops every request behind it — including the one the new row asks for once it measures. The list parked one row short, and the next send's request cleared the stale one, so every other send looked fine. Patch settles a zero-distance scroll when it is issued instead of waiting for an event that cannot come. Measured over CDP on the running app: unpatched, 3 of 6 sends stayed 26px short with scrollTop never moving; patched, 46 of 46 sends landed at the end, and reverting the patch on the same build brought the failure straight back. Opening conversations was unaffected either way (5 of 5 scrollable threads landed at the end). Also moves the 16px gap above the input off the list and onto its container. The list feeds its own paddingBottom into content size, the end target and the at-end threshold, so outside the scroller is the simpler place for it. This is not what caused the regression — the shortfall was exactly one message row — it just keeps that arithmetic to message sizes. The send flow now reads the scroller's distance from the end across three sends rather than trusting that the sent message is visible, which it is either way. It refuses to pass on a thread with no scrollable history. --- shared/chat/conversation/list-area/index.tsx | 6 +- shared/patches/@legendapp+list+3.3.6.patch | 156 ++++++++++++++++++ .../electron/flows/chat-send-message.test.ts | 40 +++++ 3 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 shared/patches/@legendapp+list+3.3.6.patch diff --git a/shared/chat/conversation/list-area/index.tsx b/shared/chat/conversation/list-area/index.tsx index 40a7a078a1b8..cc8f40a8d7ec 100644 --- a/shared/chat/conversation/list-area/index.tsx +++ b/shared/chat/conversation/list-area/index.tsx @@ -565,6 +565,11 @@ const useDesktopStyles = Kb.Styles.createStyleHook( isElectron: { ...Kb.Styles.globalStyles.fillAbsolute, overflow: 'hidden', + // The gap above the input lives out here, not as the list's own paddingBottom: the list + // feeds its padding into every scroll-offset calculation it makes (content size, the end + // target, the at-end threshold), so keeping it outside the scroller keeps that math on + // message sizes alone. + paddingBottom: 16, }, }), list: Kb.Styles.platformStyles({ @@ -573,7 +578,6 @@ const useDesktopStyles = Kb.Styles.createStyleHook( outline: 'none', overflowY: 'auto', overscrollBehavior: 'contain', - paddingBottom: 16, scrollbarGutter: 'stable', willChange: 'transform', }, diff --git a/shared/patches/@legendapp+list+3.3.6.patch b/shared/patches/@legendapp+list+3.3.6.patch new file mode 100644 index 000000000000..c58f8b4186ac --- /dev/null +++ b/shared/patches/@legendapp+list+3.3.6.patch @@ -0,0 +1,156 @@ +diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js +index fb8bb12..afc2162 100644 +--- a/node_modules/@legendapp/list/react-native.js ++++ b/node_modules/@legendapp/list/react-native.js +@@ -2333,6 +2333,11 @@ function scrollTo(ctx, params) { + } + state.scrollPending = targetOffset; + syncInitialScrollNativeWatchdog(state, { isInitialScroll, requestedOffset: offset, targetOffset }); ++ // A request for the offset the list already holds moves nothing, so no scroll event will arrive ++ // to settle it. Settle it below instead of waiting: while it counts as in flight, ++ // scrollRequestTracker.runNowIfIdle drops every request made behind it, which silently loses the ++ // maintainScrollAtEnd that a newly measured row asks for. ++ const isZeroDistanceScroll = !isInitialScroll && !noScrollingTo && !animated && Math.abs(state.scroll - targetOffset) <= 1; + if (!isInitialScroll && !noScrollingTo && Math.abs(state.scroll - targetOffset) > 1) { + if (animated) { + if (state.scrollTargetPinnedRange) { +@@ -2347,6 +2352,9 @@ function scrollTo(ctx, params) { + } else { + state.scroll = offset; + } ++ if (isZeroDistanceScroll) { ++ finishScrollTo(ctx); ++ } + } + + // src/core/scrollToIndex.ts +diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs +index 454c549..ed83fa1 100644 +--- a/node_modules/@legendapp/list/react-native.mjs ++++ b/node_modules/@legendapp/list/react-native.mjs +@@ -2312,6 +2312,11 @@ function scrollTo(ctx, params) { + } + state.scrollPending = targetOffset; + syncInitialScrollNativeWatchdog(state, { isInitialScroll, requestedOffset: offset, targetOffset }); ++ // A request for the offset the list already holds moves nothing, so no scroll event will arrive ++ // to settle it. Settle it below instead of waiting: while it counts as in flight, ++ // scrollRequestTracker.runNowIfIdle drops every request made behind it, which silently loses the ++ // maintainScrollAtEnd that a newly measured row asks for. ++ const isZeroDistanceScroll = !isInitialScroll && !noScrollingTo && !animated && Math.abs(state.scroll - targetOffset) <= 1; + if (!isInitialScroll && !noScrollingTo && Math.abs(state.scroll - targetOffset) > 1) { + if (animated) { + if (state.scrollTargetPinnedRange) { +@@ -2326,6 +2331,9 @@ function scrollTo(ctx, params) { + } else { + state.scroll = offset; + } ++ if (isZeroDistanceScroll) { ++ finishScrollTo(ctx); ++ } + } + + // src/core/scrollToIndex.ts +diff --git a/node_modules/@legendapp/list/react-native.web.js b/node_modules/@legendapp/list/react-native.web.js +index 1ae6f6b..9a52d66 100644 +--- a/node_modules/@legendapp/list/react-native.web.js ++++ b/node_modules/@legendapp/list/react-native.web.js +@@ -2177,6 +2177,11 @@ function scrollTo(ctx, params) { + } + state.scrollPending = targetOffset; + syncInitialScrollNativeWatchdog(state, { isInitialScroll, requestedOffset: offset, targetOffset }); ++ // A request for the offset the list already holds moves nothing, so no scroll event will arrive ++ // to settle it. Settle it below instead of waiting: while it counts as in flight, ++ // scrollRequestTracker.runNowIfIdle drops every request made behind it, which silently loses the ++ // maintainScrollAtEnd that a newly measured row asks for. ++ const isZeroDistanceScroll = !isInitialScroll && !noScrollingTo && !animated && Math.abs(state.scroll - targetOffset) <= 1; + if (!isInitialScroll && !noScrollingTo && Math.abs(state.scroll - targetOffset) > 1) { + if (animated) { + if (state.scrollTargetPinnedRange) { +@@ -2191,6 +2196,9 @@ function scrollTo(ctx, params) { + } else { + state.scroll = offset; + } ++ if (isZeroDistanceScroll) { ++ finishScrollTo(ctx); ++ } + } + + // src/core/scrollToIndex.ts +diff --git a/node_modules/@legendapp/list/react-native.web.mjs b/node_modules/@legendapp/list/react-native.web.mjs +index ebeced7..8205bfe 100644 +--- a/node_modules/@legendapp/list/react-native.web.mjs ++++ b/node_modules/@legendapp/list/react-native.web.mjs +@@ -2156,6 +2156,11 @@ function scrollTo(ctx, params) { + } + state.scrollPending = targetOffset; + syncInitialScrollNativeWatchdog(state, { isInitialScroll, requestedOffset: offset, targetOffset }); ++ // A request for the offset the list already holds moves nothing, so no scroll event will arrive ++ // to settle it. Settle it below instead of waiting: while it counts as in flight, ++ // scrollRequestTracker.runNowIfIdle drops every request made behind it, which silently loses the ++ // maintainScrollAtEnd that a newly measured row asks for. ++ const isZeroDistanceScroll = !isInitialScroll && !noScrollingTo && !animated && Math.abs(state.scroll - targetOffset) <= 1; + if (!isInitialScroll && !noScrollingTo && Math.abs(state.scroll - targetOffset) > 1) { + if (animated) { + if (state.scrollTargetPinnedRange) { +@@ -2170,6 +2175,9 @@ function scrollTo(ctx, params) { + } else { + state.scroll = offset; + } ++ if (isZeroDistanceScroll) { ++ finishScrollTo(ctx); ++ } + } + + // src/core/scrollToIndex.ts +diff --git a/node_modules/@legendapp/list/react.js b/node_modules/@legendapp/list/react.js +index 1ae6f6b..9a52d66 100644 +--- a/node_modules/@legendapp/list/react.js ++++ b/node_modules/@legendapp/list/react.js +@@ -2177,6 +2177,11 @@ function scrollTo(ctx, params) { + } + state.scrollPending = targetOffset; + syncInitialScrollNativeWatchdog(state, { isInitialScroll, requestedOffset: offset, targetOffset }); ++ // A request for the offset the list already holds moves nothing, so no scroll event will arrive ++ // to settle it. Settle it below instead of waiting: while it counts as in flight, ++ // scrollRequestTracker.runNowIfIdle drops every request made behind it, which silently loses the ++ // maintainScrollAtEnd that a newly measured row asks for. ++ const isZeroDistanceScroll = !isInitialScroll && !noScrollingTo && !animated && Math.abs(state.scroll - targetOffset) <= 1; + if (!isInitialScroll && !noScrollingTo && Math.abs(state.scroll - targetOffset) > 1) { + if (animated) { + if (state.scrollTargetPinnedRange) { +@@ -2191,6 +2196,9 @@ function scrollTo(ctx, params) { + } else { + state.scroll = offset; + } ++ if (isZeroDistanceScroll) { ++ finishScrollTo(ctx); ++ } + } + + // src/core/scrollToIndex.ts +diff --git a/node_modules/@legendapp/list/react.mjs b/node_modules/@legendapp/list/react.mjs +index ebeced7..8205bfe 100644 +--- a/node_modules/@legendapp/list/react.mjs ++++ b/node_modules/@legendapp/list/react.mjs +@@ -2156,6 +2156,11 @@ function scrollTo(ctx, params) { + } + state.scrollPending = targetOffset; + syncInitialScrollNativeWatchdog(state, { isInitialScroll, requestedOffset: offset, targetOffset }); ++ // A request for the offset the list already holds moves nothing, so no scroll event will arrive ++ // to settle it. Settle it below instead of waiting: while it counts as in flight, ++ // scrollRequestTracker.runNowIfIdle drops every request made behind it, which silently loses the ++ // maintainScrollAtEnd that a newly measured row asks for. ++ const isZeroDistanceScroll = !isInitialScroll && !noScrollingTo && !animated && Math.abs(state.scroll - targetOffset) <= 1; + if (!isInitialScroll && !noScrollingTo && Math.abs(state.scroll - targetOffset) > 1) { + if (animated) { + if (state.scrollTargetPinnedRange) { +@@ -2170,6 +2175,9 @@ function scrollTo(ctx, params) { + } else { + state.scroll = offset; + } ++ if (isZeroDistanceScroll) { ++ finishScrollTo(ctx); ++ } + } + + // src/core/scrollToIndex.ts diff --git a/shared/tests/e2e/electron/flows/chat-send-message.test.ts b/shared/tests/e2e/electron/flows/chat-send-message.test.ts index 8838361f657b..b84fcd8c9a10 100644 --- a/shared/tests/e2e/electron/flows/chat-send-message.test.ts +++ b/shared/tests/e2e/electron/flows/chat-send-message.test.ts @@ -33,4 +33,44 @@ test('send a message to KB_SMOKE_USER', async ({page}, testInfo) => { page.locator(`[data-testid="${CHAT_MESSAGE_LIST}"]`).getByText(testMessage) ).toBeVisible({timeout: 5_000}) }) + + // Reads the scroller rather than the message's visibility: a thread parked one row short of the + // end still shows the message it just sent, so only the distance catches it. Three sends because + // the regression this covers alternated — a keep-at-end request that moved nothing left the next + // one to be dropped, so every second send landed correctly. + await test.step('list stays at the end across sends', async () => { + // The shared tsconfig stubs `document` for react-native, so the browser side of evaluate() + // describes only the nodes it touches. + type ScrollNode = { + clientHeight: number + querySelectorAll: (selector: string) => ArrayLike + scrollHeight: number + scrollTop: number + } + const distanceFromEnd = async () => + page.evaluate(testId => { + const doc = document as unknown as {querySelector: (s: string) => ScrollNode | null} + const wrapper = doc.querySelector(`[data-testid="${testId}"]`) + if (!wrapper) return undefined + const scroller = [wrapper, ...Array.from(wrapper.querySelectorAll('*'))].find( + el => el.scrollHeight - el.clientHeight > 4 + ) + if (!scroller) return undefined + return scroller.scrollHeight - scroller.clientHeight - scroller.scrollTop + }, CHAT_MESSAGE_LIST) + + // A thread shorter than its viewport has no end to fall short of, so it would pass vacuously. + const initial = await distanceFromEnd() + expect(initial, 'conversation has no scrollable history to test against').not.toBeUndefined() + + const input = page.getByTestId(CHAT_INPUT) + for (let i = 0; i < 3; i++) { + await input.click() + await input.fill(`e2e-stick-${Date.now()}-${i}`) + await input.press('Enter') + await expect + .poll(distanceFromEnd, {message: `send ${i} left the list short of the end`, timeout: 5_000}) + .toBeLessThanOrEqual(8) + } + }) }) From 470285265dc232ad2ee92fd60bde87070addb625 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 17 Aug 2026 14:45:07 -0400 Subject: [PATCH 03/11] fix(chat): stop the composer fighting the thread's end anchor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sending a message left the desktop thread one row short of the end, and the next send made up the missing pixels — so every other send parked short. onSubmit called scrollToBottom() on top of maintainScrollAtEnd, and that imperative scroll loses the race in both directions. Its target is resolved before the sent row has measured, so it lands at the end as it was one row ago; and while it counts as in flight the list declines its own end-anchor request (scrollRequestTracker.runNowIfIdle refuses to supersede an explicit scroll) and then abandons it, so nothing follows the row once it does measure. Skipping the scroll when the list is already at the end leaves the anchor to do the job it was already doing. The jump-from-far-away case still scrolls, which is what scrollToBottom is for. Reproduced standalone first, in a legend-list example-web fixture appending one row at a time against library source: with the composer's scrollToEnd on the append, 8 of 8 appends parked 26px short; with it off, 0 of 8. Toggling it is the only variable. In the app on stock 3.3.6: 10 of 10 sends land at the end, and reverting just this guard on the same build brings back 5 of 8 short. Also drops patches/@legendapp+list+3.3.6.patch, added in the previous commit. The standalone fixture shows it does not fix this: 8 of 8 appends stay short with it applied. It changed the app's timing rather than the behaviour, and the measurements that seemed to vindicate it were mis-attributed. The gap it was aimed at is real but is the library's business, not ours: an end anchor declined behind an in-flight scroll is dropped with no replay. Left to upstream, with the fixture as the reproduction if it is worth filing. --- shared/chat/conversation/list-area/index.tsx | 5 + shared/patches/@legendapp+list+3.3.6.patch | 156 ------------------- 2 files changed, 5 insertions(+), 156 deletions(-) delete mode 100644 shared/patches/@legendapp+list+3.3.6.patch diff --git a/shared/chat/conversation/list-area/index.tsx b/shared/chat/conversation/list-area/index.tsx index cc8f40a8d7ec..0fd9e0b8dc56 100644 --- a/shared/chat/conversation/list-area/index.tsx +++ b/shared/chat/conversation/list-area/index.tsx @@ -239,6 +239,11 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() { // Imperative scroll for ThreadRefsContext const scrollToBottom = React.useCallback(() => { + // Already at the end (the common case when sending): leave it to maintainScrollAtEnd. Scrolling + // here loses the race in both directions — the target resolves before the new row has measured, + // so it lands one row short, and while it counts as in flight the list declines its own end + // anchor and abandons it, so nothing follows the row once it does measure. + if (listRef.current?.getState().isAtEnd) return void listRef.current?.scrollToEnd({animated: false}) }, []) diff --git a/shared/patches/@legendapp+list+3.3.6.patch b/shared/patches/@legendapp+list+3.3.6.patch deleted file mode 100644 index c58f8b4186ac..000000000000 --- a/shared/patches/@legendapp+list+3.3.6.patch +++ /dev/null @@ -1,156 +0,0 @@ -diff --git a/node_modules/@legendapp/list/react-native.js b/node_modules/@legendapp/list/react-native.js -index fb8bb12..afc2162 100644 ---- a/node_modules/@legendapp/list/react-native.js -+++ b/node_modules/@legendapp/list/react-native.js -@@ -2333,6 +2333,11 @@ function scrollTo(ctx, params) { - } - state.scrollPending = targetOffset; - syncInitialScrollNativeWatchdog(state, { isInitialScroll, requestedOffset: offset, targetOffset }); -+ // A request for the offset the list already holds moves nothing, so no scroll event will arrive -+ // to settle it. Settle it below instead of waiting: while it counts as in flight, -+ // scrollRequestTracker.runNowIfIdle drops every request made behind it, which silently loses the -+ // maintainScrollAtEnd that a newly measured row asks for. -+ const isZeroDistanceScroll = !isInitialScroll && !noScrollingTo && !animated && Math.abs(state.scroll - targetOffset) <= 1; - if (!isInitialScroll && !noScrollingTo && Math.abs(state.scroll - targetOffset) > 1) { - if (animated) { - if (state.scrollTargetPinnedRange) { -@@ -2347,6 +2352,9 @@ function scrollTo(ctx, params) { - } else { - state.scroll = offset; - } -+ if (isZeroDistanceScroll) { -+ finishScrollTo(ctx); -+ } - } - - // src/core/scrollToIndex.ts -diff --git a/node_modules/@legendapp/list/react-native.mjs b/node_modules/@legendapp/list/react-native.mjs -index 454c549..ed83fa1 100644 ---- a/node_modules/@legendapp/list/react-native.mjs -+++ b/node_modules/@legendapp/list/react-native.mjs -@@ -2312,6 +2312,11 @@ function scrollTo(ctx, params) { - } - state.scrollPending = targetOffset; - syncInitialScrollNativeWatchdog(state, { isInitialScroll, requestedOffset: offset, targetOffset }); -+ // A request for the offset the list already holds moves nothing, so no scroll event will arrive -+ // to settle it. Settle it below instead of waiting: while it counts as in flight, -+ // scrollRequestTracker.runNowIfIdle drops every request made behind it, which silently loses the -+ // maintainScrollAtEnd that a newly measured row asks for. -+ const isZeroDistanceScroll = !isInitialScroll && !noScrollingTo && !animated && Math.abs(state.scroll - targetOffset) <= 1; - if (!isInitialScroll && !noScrollingTo && Math.abs(state.scroll - targetOffset) > 1) { - if (animated) { - if (state.scrollTargetPinnedRange) { -@@ -2326,6 +2331,9 @@ function scrollTo(ctx, params) { - } else { - state.scroll = offset; - } -+ if (isZeroDistanceScroll) { -+ finishScrollTo(ctx); -+ } - } - - // src/core/scrollToIndex.ts -diff --git a/node_modules/@legendapp/list/react-native.web.js b/node_modules/@legendapp/list/react-native.web.js -index 1ae6f6b..9a52d66 100644 ---- a/node_modules/@legendapp/list/react-native.web.js -+++ b/node_modules/@legendapp/list/react-native.web.js -@@ -2177,6 +2177,11 @@ function scrollTo(ctx, params) { - } - state.scrollPending = targetOffset; - syncInitialScrollNativeWatchdog(state, { isInitialScroll, requestedOffset: offset, targetOffset }); -+ // A request for the offset the list already holds moves nothing, so no scroll event will arrive -+ // to settle it. Settle it below instead of waiting: while it counts as in flight, -+ // scrollRequestTracker.runNowIfIdle drops every request made behind it, which silently loses the -+ // maintainScrollAtEnd that a newly measured row asks for. -+ const isZeroDistanceScroll = !isInitialScroll && !noScrollingTo && !animated && Math.abs(state.scroll - targetOffset) <= 1; - if (!isInitialScroll && !noScrollingTo && Math.abs(state.scroll - targetOffset) > 1) { - if (animated) { - if (state.scrollTargetPinnedRange) { -@@ -2191,6 +2196,9 @@ function scrollTo(ctx, params) { - } else { - state.scroll = offset; - } -+ if (isZeroDistanceScroll) { -+ finishScrollTo(ctx); -+ } - } - - // src/core/scrollToIndex.ts -diff --git a/node_modules/@legendapp/list/react-native.web.mjs b/node_modules/@legendapp/list/react-native.web.mjs -index ebeced7..8205bfe 100644 ---- a/node_modules/@legendapp/list/react-native.web.mjs -+++ b/node_modules/@legendapp/list/react-native.web.mjs -@@ -2156,6 +2156,11 @@ function scrollTo(ctx, params) { - } - state.scrollPending = targetOffset; - syncInitialScrollNativeWatchdog(state, { isInitialScroll, requestedOffset: offset, targetOffset }); -+ // A request for the offset the list already holds moves nothing, so no scroll event will arrive -+ // to settle it. Settle it below instead of waiting: while it counts as in flight, -+ // scrollRequestTracker.runNowIfIdle drops every request made behind it, which silently loses the -+ // maintainScrollAtEnd that a newly measured row asks for. -+ const isZeroDistanceScroll = !isInitialScroll && !noScrollingTo && !animated && Math.abs(state.scroll - targetOffset) <= 1; - if (!isInitialScroll && !noScrollingTo && Math.abs(state.scroll - targetOffset) > 1) { - if (animated) { - if (state.scrollTargetPinnedRange) { -@@ -2170,6 +2175,9 @@ function scrollTo(ctx, params) { - } else { - state.scroll = offset; - } -+ if (isZeroDistanceScroll) { -+ finishScrollTo(ctx); -+ } - } - - // src/core/scrollToIndex.ts -diff --git a/node_modules/@legendapp/list/react.js b/node_modules/@legendapp/list/react.js -index 1ae6f6b..9a52d66 100644 ---- a/node_modules/@legendapp/list/react.js -+++ b/node_modules/@legendapp/list/react.js -@@ -2177,6 +2177,11 @@ function scrollTo(ctx, params) { - } - state.scrollPending = targetOffset; - syncInitialScrollNativeWatchdog(state, { isInitialScroll, requestedOffset: offset, targetOffset }); -+ // A request for the offset the list already holds moves nothing, so no scroll event will arrive -+ // to settle it. Settle it below instead of waiting: while it counts as in flight, -+ // scrollRequestTracker.runNowIfIdle drops every request made behind it, which silently loses the -+ // maintainScrollAtEnd that a newly measured row asks for. -+ const isZeroDistanceScroll = !isInitialScroll && !noScrollingTo && !animated && Math.abs(state.scroll - targetOffset) <= 1; - if (!isInitialScroll && !noScrollingTo && Math.abs(state.scroll - targetOffset) > 1) { - if (animated) { - if (state.scrollTargetPinnedRange) { -@@ -2191,6 +2196,9 @@ function scrollTo(ctx, params) { - } else { - state.scroll = offset; - } -+ if (isZeroDistanceScroll) { -+ finishScrollTo(ctx); -+ } - } - - // src/core/scrollToIndex.ts -diff --git a/node_modules/@legendapp/list/react.mjs b/node_modules/@legendapp/list/react.mjs -index ebeced7..8205bfe 100644 ---- a/node_modules/@legendapp/list/react.mjs -+++ b/node_modules/@legendapp/list/react.mjs -@@ -2156,6 +2156,11 @@ function scrollTo(ctx, params) { - } - state.scrollPending = targetOffset; - syncInitialScrollNativeWatchdog(state, { isInitialScroll, requestedOffset: offset, targetOffset }); -+ // A request for the offset the list already holds moves nothing, so no scroll event will arrive -+ // to settle it. Settle it below instead of waiting: while it counts as in flight, -+ // scrollRequestTracker.runNowIfIdle drops every request made behind it, which silently loses the -+ // maintainScrollAtEnd that a newly measured row asks for. -+ const isZeroDistanceScroll = !isInitialScroll && !noScrollingTo && !animated && Math.abs(state.scroll - targetOffset) <= 1; - if (!isInitialScroll && !noScrollingTo && Math.abs(state.scroll - targetOffset) > 1) { - if (animated) { - if (state.scrollTargetPinnedRange) { -@@ -2170,6 +2175,9 @@ function scrollTo(ctx, params) { - } else { - state.scroll = offset; - } -+ if (isZeroDistanceScroll) { -+ finishScrollTo(ctx); -+ } - } - - // src/core/scrollToIndex.ts From 1e8b790b34dba3b44f4b3186d2d23ad56f50555c Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 17 Aug 2026 15:03:00 -0400 Subject: [PATCH 04/11] fix(chat): measure the hover reaction bar against the scroller, halve the gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gap above the input area is padding on .chat-message-list, so the scroller inside it ends above that box. keepWithinList measured the bar against the outer box, which left the lowest row's bar hanging in the padding where the scroller clipped it: bar bottom 867 against a scroller ending at 851, with no raise applied because the outer box still had room. Measure against the scroller instead — the one ancestor sitting directly inside the outer box. Not the "nearest scrollable ancestor": every row container reports as one, which lifts every row by its own height instead. The raise also stacked onto itself. measure() runs on mount and again on pointer re-entry, and it added back the raise from a ref that React had not painted yet, so the second pass could double it. It now reads the raise the DOM is actually carrying. At the old 16px gap those two bugs cancelled at some viewport heights, which is why this only surfaced when the padding moved. Halves the gap to 8px while here, as asked. Measured live, hovering the four lowest rows and reading the bar against the scroller's bottom edge: before, 16px gap: lowest row overflows by 16px, raise 0 FAIL before, 8px gap: flush, but raise 16 where 8 was due (cancelling) after, 16px gap: flush at 851, raise 16, other rows raise 0 PASS after, 8px gap: flush at 859, raise 16, other rows raise 0 PASS Staleness still holds, which is what the re-entry listener is for: hovering a row mid-list (raise 0), scrolling it down to the bottom edge, then re-hovering re-measures to raise 16 and lands flush. --- shared/chat/conversation/list-area/index.tsx | 2 +- .../chat/conversation/messages/emoji-row.tsx | 32 +++++++++++++++---- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/shared/chat/conversation/list-area/index.tsx b/shared/chat/conversation/list-area/index.tsx index 0fd9e0b8dc56..43acb4586c89 100644 --- a/shared/chat/conversation/list-area/index.tsx +++ b/shared/chat/conversation/list-area/index.tsx @@ -574,7 +574,7 @@ const useDesktopStyles = Kb.Styles.createStyleHook( // feeds its padding into every scroll-offset calculation it makes (content size, the end // target, the at-end threshold), so keeping it outside the scroller keeps that math on // message sizes alone. - paddingBottom: 16, + paddingBottom: 8, }, }), list: Kb.Styles.platformStyles({ diff --git a/shared/chat/conversation/messages/emoji-row.tsx b/shared/chat/conversation/messages/emoji-row.tsx index 66549c77d9a9..84b018518c13 100644 --- a/shared/chat/conversation/messages/emoji-row.tsx +++ b/shared/chat/conversation/messages/emoji-row.tsx @@ -74,7 +74,6 @@ function EmojiRowContainer(p: OwnProps) { // on every pointer re-entry into the row. Typed structurally since the native tsconfig has no // DOM lib; on desktop Box2's MeasureRef is the backing div. const [raiseBy, setRaiseBy] = React.useState(0) - const raiseByRef = React.useRef(0) React.useLayoutEffect(() => { if (isMobile || !keepWithinList) { return @@ -83,24 +82,43 @@ function EmojiRowContainer(p: OwnProps) { addEventListener: (t: string, cb: () => void) => void removeEventListener: (t: string, cb: () => void) => void getBoundingClientRect: () => {bottom: number; height: number} + parentElement: ElLike | null } const el = popupAnchor.current as null | (ElLike & {closest?: (sel: string) => ElLike | null}) const row = el?.closest?.('.WrapperMessage-hoverBox') - const list = el?.closest?.('.chat-message-list') + const outer = el?.closest?.('.chat-message-list') + // Measure against the scroller, not the outer box: the gap above the input area is padding on + // the outer box, so the scroller ends above it and clips a bar the outer box still had room + // for. The scroller is the one ancestor sitting directly inside the outer box — don't look for + // "nearest scrollable ancestor", every row container reports as one. + let list = outer + for (let node = el?.parentElement; node; node = node.parentElement) { + if (node.parentElement === outer) { + list = node + break + } + } if (!el || !row || !list) { return } + // Read back the raise the DOM is actually carrying rather than the one we last asked for: the two + // differ for as long as it takes React to paint our state, and measuring twice inside that gap + // (which mount + the enter event do) would otherwise stack the raise onto itself. + const appliedRaise = () => { + const getStyle = (globalThis as unknown as {getComputedStyle?: (e: unknown) => {transform: string}}) + .getComputedStyle + const matrix = /matrix\([^)]*,\s*(-?[\d.]+)\)\s*$/.exec(getStyle?.(el).transform ?? '') + return matrix ? -Number(matrix[1]) : 0 + } const measure = () => { const rect = el.getBoundingClientRect() // zero height means we're display:none and the rect is meaningless if (rect.height === 0) { return } - // add back the raise we're currently applying so overflow is computed from our natural spot - const overflow = rect.bottom + raiseByRef.current - list.getBoundingClientRect().bottom - const next = Math.max(0, overflow) - raiseByRef.current = next - setRaiseBy(next) + // add back the raise in effect so overflow is computed from our natural spot + const overflow = rect.bottom + appliedRaise() - list.getBoundingClientRect().bottom + setRaiseBy(Math.max(0, overflow)) } // the enter event that mounted us already fired, so measure directly too measure() From decc0cd7ea113085d023556477c24f22ae63890a Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 17 Aug 2026 15:05:38 -0400 Subject: [PATCH 05/11] fix(chat): clear the list edge by 2px when raising the hover reaction bar A raised bar landed flush on the scroller's bottom edge, which read as tight against the input area. Raise by the overflow plus 2px so it clears the edge. Rows that already fit are still left alone. Lowest row now sits at 857 against a scroller ending at 859, raise 18; rows with room raise 0. Re-measuring on re-entry still holds: hovering a row mid-list (raise 0), scrolling it to the bottom edge and re-hovering gives raise 18. --- shared/chat/conversation/messages/emoji-row.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/shared/chat/conversation/messages/emoji-row.tsx b/shared/chat/conversation/messages/emoji-row.tsx index 84b018518c13..101df01d04f2 100644 --- a/shared/chat/conversation/messages/emoji-row.tsx +++ b/shared/chat/conversation/messages/emoji-row.tsx @@ -12,6 +12,9 @@ import { useConversationThreadMessageActions, } from '../thread-context' +// A raised hover bar stops this far above the scroller's bottom edge instead of flush against it. +const edgeClearance = 2 + type OwnProps = { className?: string hasUnfurls: boolean @@ -118,7 +121,9 @@ function EmojiRowContainer(p: OwnProps) { } // add back the raise in effect so overflow is computed from our natural spot const overflow = rect.bottom + appliedRaise() - list.getBoundingClientRect().bottom - setRaiseBy(Math.max(0, overflow)) + // clear the edge rather than sitting flush on it, so a raised bar doesn't read as tight + // against the input area + setRaiseBy(overflow > 0 ? overflow + edgeClearance : 0) } // the enter event that mounted us already fired, so measure directly too measure() From 20a839a2ef138e057227597e5a0cdc7d20786edb Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 17 Aug 2026 15:26:11 -0400 Subject: [PATCH 06/11] fix(chat): keep the composer out of the thread's height, follow layout changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A multi-line message did not stick to the bottom. Composing one grew the composer a line at a time, and since the composer is a flex sibling of the list, each line took 22px of list viewport. The list did not re-pin as it shrank, so by six lines it sat 110px above the newest message — before anything was even sent. Two independent causes, each measured on its own. The composer now reserves the height it has when empty and grows upward over the thread from there, so typing never resizes the list. The reserve is measured rather than hardcoded: the resting composer is not a fixed height, and the measurement tracks the smallest height seen since the last window resize, which is right from the first frame because a conversation opens with an empty composer. Cost of this shape, which is the point of it: a tall composer covers the newest messages while you type. before: list viewport 779 -> 669 across six composed lines, distance from the end 0.5 -> 110.5, and 5 of 5 six-line sends landed 114px short after: viewport stays 779 at every composer height, distance stays 0.5, and 6-line and 12-line sends land at the end maintainScrollAtEnd also gets its layout trigger back. Naming any trigger opts out of every trigger left unnamed, so listing dataChange/footerLayout/itemLayout silently disabled the one that fires when the viewport itself changes. That is a real bug on its own, independent of the composer: before: shrinking the window viewport by 200px left the thread 200px short of the end, by 120px left it 120px short after: pinned at both Single-line sends still land (6 of 6), and the hover reaction bar still clears the list edge. --- shared/chat/conversation/list-area/index.tsx | 5 +- shared/chat/conversation/normal/index.tsx | 67 +++++++++++++++++++- 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/shared/chat/conversation/list-area/index.tsx b/shared/chat/conversation/list-area/index.tsx index 43acb4586c89..bf4fe58c35f3 100644 --- a/shared/chat/conversation/list-area/index.tsx +++ b/shared/chat/conversation/list-area/index.tsx @@ -546,7 +546,10 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() { maintainScrollAtEnd={ centeredOrdinal !== undefined ? false - : {on: {dataChange: true, footerLayout: true, itemLayout: true}} + : // layout included deliberately: naming any trigger opts out of every trigger left + // unnamed, and without it the thread loses the end whenever this viewport shrinks — + // shrinking the window by 200px left it 200px short of the newest message. + {on: {dataChange: true, footerLayout: true, itemLayout: true, layout: true}} } // Stays on while centered: the full thread response lands after the cached one and // re-measures rows above the target, which slides it out of view unless anchored. diff --git a/shared/chat/conversation/normal/index.tsx b/shared/chat/conversation/normal/index.tsx index 4a16c1b89849..0c3f44e09975 100644 --- a/shared/chat/conversation/normal/index.tsx +++ b/shared/chat/conversation/normal/index.tsx @@ -47,6 +47,58 @@ const LoadingLine = () => { return showLoader ? : null } +// Keeps the composer out of the column's height arithmetic: it reserves the height it has when empty +// and grows upward over the thread from there, so typing a long message no longer resizes the list. +// The reserve is measured rather than hardcoded because the resting composer is not a fixed height, +// and it tracks the smallest height seen since the last window resize — the composer is empty when a +// conversation opens, so the first measurement is already the right one. +const BottomInput = function BottomInput() { + const styles = useDesktopStyles() + const [reserved, setReserved] = React.useState(undefined) + const innerRef = React.useRef(null) + React.useLayoutEffect(() => { + type ElLike = {getBoundingClientRect: () => {height: number}} + const el = innerRef.current as ElLike | null + if (!el) { + return + } + const measure = () => { + const {height} = el.getBoundingClientRect() + if (height > 0) { + setReserved(prev => (prev === undefined ? height : Math.min(prev, height))) + } + } + measure() + const g = globalThis as unknown as { + ResizeObserver?: new (cb: () => void) => {observe: (t: unknown) => void; disconnect: () => void} + addEventListener?: (t: string, cb: () => void) => void + removeEventListener?: (t: string, cb: () => void) => void + } + const observer = g.ResizeObserver ? new g.ResizeObserver(measure) : undefined + observer?.observe(el) + // A narrower window can leave the composer legitimately taller at rest, so start the minimum over. + const onResize = () => setReserved(undefined) + g.addEventListener?.('resize', onResize) + return () => { + observer?.disconnect() + g.removeEventListener?.('resize', onResize) + } + }, []) + + return ( +
+ + + +
+ ) +} + const DesktopConversation = function DesktopConversation() { const desktopStyles = useDesktopStyles() const conversationIDKey = useConversationThreadID() @@ -110,7 +162,7 @@ const DesktopConversation = function DesktopConversation() { - + @@ -192,13 +244,24 @@ const NativeConversation = function NativeConversation() { } const useDesktopStyles = Kb.Styles.createStyleHook( - () => + theme => ({ container: { ...Kb.Styles.globalStyles.flexBoxColumn, flex: 1, position: 'relative', }, + // Anchored to the bottom of the reserve its wrapper holds open, so extra lines extend upward + // over the thread instead of pushing the list shorter. Opaque so messages don't show through. + inputAnchored: { + backgroundColor: theme.white, + bottom: 0, + left: 0, + position: 'absolute' as const, + right: 0, + }, + // Only until the first measurement lands, so the wrapper has something to measure. + inputInFlow: {}, overlayTop: { left: 0, position: 'absolute' as const, From d9a4c252c79a5d74b4e0f3598c3816e39f6e1e99 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 17 Aug 2026 15:43:22 -0400 Subject: [PATCH 07/11] fix(chat): let the composer push the list again, hold the end with a settle loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restores the composer as a flex sibling of the thread, so growing it shortens the list the way it used to, and makes landing at the end reliable instead of relying on the layout being calm. Sending is not one event: the composer collapses and a row is appended in the same frame, so the viewport grows while the content does, and the row only reaches its real height once measured. Any single scroll resolves its target against one moment of that and lands short. scrollToBottom now re-aims until the scroller sits at its end for two consecutive readings, bounded at 1.5s, and gives up if the reader touches the wheel. It reads the scroller's own scrollHeight/clientHeight/scrollTop rather than the list's isAtEnd, because that flag is derived from the content size and viewport the list has recorded and both lag a composer collapse — trusting it left 1 in 10 two-line sends 42px short, since two readings 50ms apart can both be stale-true. Measured per message size, sends landing within 8px of the end: 1 line 10/10, 2 lines 10/10, 3 lines 10/10, 4 lines 6/6, 6 lines 6/6, 8 lines 6/6, 10 lines 6/6, 16 lines 6/6 With the loop reduced to a single scroll, 4-line sends went 5 of 6 short at 77.5px and six-line ones compounded to 228.5px, so the loop is doing the work. Rapid one-line sends 10/10, the window-resize pinning from the layout trigger still holds, and the hover reaction bar still clears the list edge. --- shared/chat/conversation/list-area/index.tsx | 59 ++++++++++++++--- shared/chat/conversation/normal/index.tsx | 67 +------------------- 2 files changed, 53 insertions(+), 73 deletions(-) diff --git a/shared/chat/conversation/list-area/index.tsx b/shared/chat/conversation/list-area/index.tsx index bf4fe58c35f3..61a8a52127b7 100644 --- a/shared/chat/conversation/list-area/index.tsx +++ b/shared/chat/conversation/list-area/index.tsx @@ -139,6 +139,8 @@ const usePagination = (p: { } const centerTolerancePx = 8 +// A scroller within this many pixels of its end reads as at the end. +const endTolerancePx = 2 // When a centeredOrdinal is set at mount, start there; otherwise start at the end (newest). const useInitialScrollIndex = ( @@ -237,15 +239,55 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() { const getItemType = useGetItemType() + // Closed loop, not one shot. Sending collapses the composer and appends a row in the same frame, so + // the viewport grows while the content does, and the row only reaches its real height once it has + // measured. Any single scroll resolves its target against one moment of that and lands short — a + // four-line message landed 78px short. Re-aim until the scroller sits at its end and holds there, + // then stop so maintainScrollAtEnd owns the position again. + const stickLoopRef = React.useRef<{cancelled: boolean} | undefined>(undefined) + const abortStickToEnd = React.useCallback(() => { + if (stickLoopRef.current) stickLoopRef.current.cancelled = true + }, []) + React.useEffect(() => abortStickToEnd, [abortStickToEnd]) + + // Read the scroller itself rather than the list's own isAtEnd: that flag comes from the content size + // and viewport the list has recorded, both of which lag a composer collapse and a fresh row's + // measurement, so it reports at-end while the scroller is still short and the loop below would stop + // one composer's worth of pixels early. + const readDistanceFromEnd = React.useCallback(() => { + type ElLike = {children: ArrayLike; clientHeight: number; scrollHeight: number; scrollTop: number} + const wrapper = wrapperRef.current as unknown as ElLike | null + if (!wrapper) return undefined + for (const child of Array.from(wrapper.children)) { + if (child.scrollHeight - child.clientHeight > 1) { + return child.scrollHeight - child.clientHeight - child.scrollTop + } + } + return undefined + }, []) + // Imperative scroll for ThreadRefsContext const scrollToBottom = React.useCallback(() => { - // Already at the end (the common case when sending): leave it to maintainScrollAtEnd. Scrolling - // here loses the race in both directions — the target resolves before the new row has measured, - // so it lands one row short, and while it counts as in flight the list declines its own end - // anchor and abandons it, so nothing follows the row once it does measure. - if (listRef.current?.getState().isAtEnd) return - void listRef.current?.scrollToEnd({animated: false}) - }, []) + abortStickToEnd() + const loop = {cancelled: false} + stickLoopRef.current = loop + const run = async () => { + let settled = 0 + for (let elapsed = 0; elapsed < 1500 && !loop.cancelled; elapsed += 50) { + const distance = readDistanceFromEnd() + if (distance === undefined) return + if (distance <= endTolerancePx) { + // One reading can land in the frame before the composer collapses, so require two. + if (++settled >= 2) return + } else { + settled = 0 + void listRef.current?.scrollToEnd({animated: false}) + } + await new Promise(resolve => setTimeout(resolve, 50)) + } + } + void run() + }, [abortStickToEnd, readDistanceFromEnd]) const scrollUp = React.useCallback(() => { const state = listRef.current?.getState() @@ -515,7 +557,8 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() { // they landed. const onWheel = React.useCallback(() => { abortCentering() - }, [abortCentering]) + abortStickToEnd() + }, [abortCentering, abortStickToEnd]) return ( diff --git a/shared/chat/conversation/normal/index.tsx b/shared/chat/conversation/normal/index.tsx index 0c3f44e09975..4a16c1b89849 100644 --- a/shared/chat/conversation/normal/index.tsx +++ b/shared/chat/conversation/normal/index.tsx @@ -47,58 +47,6 @@ const LoadingLine = () => { return showLoader ? : null } -// Keeps the composer out of the column's height arithmetic: it reserves the height it has when empty -// and grows upward over the thread from there, so typing a long message no longer resizes the list. -// The reserve is measured rather than hardcoded because the resting composer is not a fixed height, -// and it tracks the smallest height seen since the last window resize — the composer is empty when a -// conversation opens, so the first measurement is already the right one. -const BottomInput = function BottomInput() { - const styles = useDesktopStyles() - const [reserved, setReserved] = React.useState(undefined) - const innerRef = React.useRef(null) - React.useLayoutEffect(() => { - type ElLike = {getBoundingClientRect: () => {height: number}} - const el = innerRef.current as ElLike | null - if (!el) { - return - } - const measure = () => { - const {height} = el.getBoundingClientRect() - if (height > 0) { - setReserved(prev => (prev === undefined ? height : Math.min(prev, height))) - } - } - measure() - const g = globalThis as unknown as { - ResizeObserver?: new (cb: () => void) => {observe: (t: unknown) => void; disconnect: () => void} - addEventListener?: (t: string, cb: () => void) => void - removeEventListener?: (t: string, cb: () => void) => void - } - const observer = g.ResizeObserver ? new g.ResizeObserver(measure) : undefined - observer?.observe(el) - // A narrower window can leave the composer legitimately taller at rest, so start the minimum over. - const onResize = () => setReserved(undefined) - g.addEventListener?.('resize', onResize) - return () => { - observer?.disconnect() - g.removeEventListener?.('resize', onResize) - } - }, []) - - return ( -
- - - -
- ) -} - const DesktopConversation = function DesktopConversation() { const desktopStyles = useDesktopStyles() const conversationIDKey = useConversationThreadID() @@ -162,7 +110,7 @@ const DesktopConversation = function DesktopConversation() { - + @@ -244,24 +192,13 @@ const NativeConversation = function NativeConversation() { } const useDesktopStyles = Kb.Styles.createStyleHook( - theme => + () => ({ container: { ...Kb.Styles.globalStyles.flexBoxColumn, flex: 1, position: 'relative', }, - // Anchored to the bottom of the reserve its wrapper holds open, so extra lines extend upward - // over the thread instead of pushing the list shorter. Opaque so messages don't show through. - inputAnchored: { - backgroundColor: theme.white, - bottom: 0, - left: 0, - position: 'absolute' as const, - right: 0, - }, - // Only until the first measurement lands, so the wrapper has something to measure. - inputInFlow: {}, overlayTop: { left: 0, position: 'absolute' as const, From 7058c0c19adf7425ef653d00feb0688e589c7471 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 17 Aug 2026 16:02:03 -0400 Subject: [PATCH 08/11] fix(chat): send after the composer collapses, drop the settle loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the 1.5s re-aiming loop with the ordering fix it was compensating for. onSubmit cleared the composer and sent in the same tick, so React committed the composer shrinking back to one line and the new row arriving as one change. The list resolved its end against a single moment of that and landed short — up to 114px for a six-line message. Clearing first and sending on the next frame lets the viewport settle before the row exists, and the list's own end anchor handles the rest. What stays in our code is the guard on scrollToBottom, and it now asks the scroller instead of the list's isAtEnd. That flag is derived from the content size and viewport the list has recorded, both of which lag a composer collapse, so it read not-at-end while the scroller was at its end; we then scrolled, landed short, and blocked the anchor. Guarding on isAtEnd left 2 of 8 two-line sends and 4 of 8 six-line ones short. scrollToBottom still exists for what it is for — returning from elsewhere in the thread — which is measured working: sending from 1500px away jumps to the end, 4 of 4. Sends landing within 8px of the end, per message size: 1 line 10/10, 2 lines 8/8, 3 lines 8/8, 6 lines 8/8, 10 lines 8/8, 16 lines 8/8 Rejected along the way, all measured: an unconditional one-shot scroll (8 of 8 short at every size, 5 of 10 single-line — it is the original bug); maintainScrollAtEndThreshold at 0.3 (six-line 8 of 8 short); at 1 it does work library-side, 30 of 30, but it means following the end from within a whole viewport, so reading a few messages up would get yanked back by anything arriving. Window-resize pinning and the hover reaction bar unchanged. --- .../conversation/input-area/normal/index.tsx | 7 ++- shared/chat/conversation/list-area/index.tsx | 59 +++++-------------- 2 files changed, 22 insertions(+), 44 deletions(-) diff --git a/shared/chat/conversation/input-area/normal/index.tsx b/shared/chat/conversation/input-area/normal/index.tsx index afc2bad9f6cb..e15a221d060c 100644 --- a/shared/chat/conversation/input-area/normal/index.tsx +++ b/shared/chat/conversation/input-area/normal/index.tsx @@ -198,8 +198,13 @@ const ConnectedPlatformInput = function ConnectedPlatformInput() { const {scrollToBottom} = React.useContext(ThreadRefsContext) const onSubmit = (text: string) => { if (!text) return + // Clear first and let that land on its own: emptying the composer shrinks it back to one line, + // which grows the thread's viewport. Sending in the same tick makes React commit that growth and + // the new row together, and the list resolves its end against one moment of the two. injectText('', true) - sendComposerText(text) + requestAnimationFrame(() => { + sendComposerText(text) + }) if (hasCenter) { toggleThreadSearch(true) jumpToRecent() diff --git a/shared/chat/conversation/list-area/index.tsx b/shared/chat/conversation/list-area/index.tsx index 61a8a52127b7..d13783315246 100644 --- a/shared/chat/conversation/list-area/index.tsx +++ b/shared/chat/conversation/list-area/index.tsx @@ -139,7 +139,7 @@ const usePagination = (p: { } const centerTolerancePx = 8 -// A scroller within this many pixels of its end reads as at the end. +// A scroller within this many pixels of its end counts as at the end. const endTolerancePx = 2 // When a centeredOrdinal is set at mount, start there; otherwise start at the end (newest). @@ -239,55 +239,29 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() { const getItemType = useGetItemType() - // Closed loop, not one shot. Sending collapses the composer and appends a row in the same frame, so - // the viewport grows while the content does, and the row only reaches its real height once it has - // measured. Any single scroll resolves its target against one moment of that and lands short — a - // four-line message landed 78px short. Re-aim until the scroller sits at its end and holds there, - // then stop so maintainScrollAtEnd owns the position again. - const stickLoopRef = React.useRef<{cancelled: boolean} | undefined>(undefined) - const abortStickToEnd = React.useCallback(() => { - if (stickLoopRef.current) stickLoopRef.current.cancelled = true - }, []) - React.useEffect(() => abortStickToEnd, [abortStickToEnd]) - - // Read the scroller itself rather than the list's own isAtEnd: that flag comes from the content size - // and viewport the list has recorded, both of which lag a composer collapse and a fresh row's - // measurement, so it reports at-end while the scroller is still short and the loop below would stop - // one composer's worth of pixels early. - const readDistanceFromEnd = React.useCallback(() => { + // Asks the scroller, not the list's own isAtEnd: that flag comes from the content size and viewport + // the list has recorded, and both lag a composer collapse, so it reads not-at-end while the scroller + // is in fact at its end. + const isScrolledToEnd = React.useCallback(() => { type ElLike = {children: ArrayLike; clientHeight: number; scrollHeight: number; scrollTop: number} const wrapper = wrapperRef.current as unknown as ElLike | null - if (!wrapper) return undefined + if (!wrapper) return false for (const child of Array.from(wrapper.children)) { if (child.scrollHeight - child.clientHeight > 1) { - return child.scrollHeight - child.clientHeight - child.scrollTop + return child.scrollHeight - child.clientHeight - child.scrollTop <= endTolerancePx } } - return undefined + return false }, []) - // Imperative scroll for ThreadRefsContext + // Imperative scroll for ThreadRefsContext: for coming back from somewhere else in the thread. When + // the list is already at the end, maintainScrollAtEnd owns that and scrolling here only gets in its + // way — the target resolves before the new row has measured, so it lands short, and while it counts + // as in flight the list declines its own end anchor and abandons it. const scrollToBottom = React.useCallback(() => { - abortStickToEnd() - const loop = {cancelled: false} - stickLoopRef.current = loop - const run = async () => { - let settled = 0 - for (let elapsed = 0; elapsed < 1500 && !loop.cancelled; elapsed += 50) { - const distance = readDistanceFromEnd() - if (distance === undefined) return - if (distance <= endTolerancePx) { - // One reading can land in the frame before the composer collapses, so require two. - if (++settled >= 2) return - } else { - settled = 0 - void listRef.current?.scrollToEnd({animated: false}) - } - await new Promise(resolve => setTimeout(resolve, 50)) - } - } - void run() - }, [abortStickToEnd, readDistanceFromEnd]) + if (isScrolledToEnd()) return + void listRef.current?.scrollToEnd({animated: false}) + }, [isScrolledToEnd]) const scrollUp = React.useCallback(() => { const state = listRef.current?.getState() @@ -557,8 +531,7 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() { // they landed. const onWheel = React.useCallback(() => { abortCentering() - abortStickToEnd() - }, [abortCentering, abortStickToEnd]) + }, [abortCentering]) return ( From 4a46b484de2a10b92439824da7d365ccdab9619b Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 17 Aug 2026 17:12:52 -0400 Subject: [PATCH 09/11] refactor(chat): take maintainScrollAtEnd's documented form, note where we differ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit legend-list documents `maintainScrollAtEnd` as a boolean and its chat example passes it that way. We passed a trigger list instead, and naming any trigger opts out of the ones left unnamed — which is how the layout trigger went missing and a window resize lost the end. Ours is now the documented form, which is what the trigger list had grown into anyway. Also records, next to each of them, why the two remaining deviations from that example exist, since both look like something to simplify away: Their example appends the message and calls scrollToEnd in the same handler. That works there because its composer is a single-line input which never resizes the list; ours is multi-line, so clearing it grows the thread's viewport and lands as one change together with the new row. Measured in that order, every send was short — 8 of 8 at one, two and six lines. Hence clearing first and sending on the next frame. Their example also scrolls unconditionally on send. We only scroll when we are not already at the end, because while the list is at the end maintainScrollAtEnd owns the position and an imperative scroll displaces it: it resolves its target before the sent row has measured, and while it counts as in flight the list declines its own end anchor. Scrolling unconditionally, animated as they do, cost 3 of 10 single-line sends; the same scroll issued on the next frame instead broke coming back from further up the thread, 1 to 2 of 4. Unchanged and re-measured on this: sends landing within 8px of the end 10/10 at one, two, six and sixteen lines; returning from 1500px away lands 4 of 4; window resize still pinned. --- .../conversation/input-area/normal/index.tsx | 8 +++++--- shared/chat/conversation/list-area/index.tsx | 16 ++++++++-------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/shared/chat/conversation/input-area/normal/index.tsx b/shared/chat/conversation/input-area/normal/index.tsx index e15a221d060c..382ca18726e5 100644 --- a/shared/chat/conversation/input-area/normal/index.tsx +++ b/shared/chat/conversation/input-area/normal/index.tsx @@ -198,9 +198,11 @@ const ConnectedPlatformInput = function ConnectedPlatformInput() { const {scrollToBottom} = React.useContext(ThreadRefsContext) const onSubmit = (text: string) => { if (!text) return - // Clear first and let that land on its own: emptying the composer shrinks it back to one line, - // which grows the thread's viewport. Sending in the same tick makes React commit that growth and - // the new row together, and the list resolves its end against one moment of the two. + // legend-list's own chat example appends and calls scrollToEnd in one go, which works there because + // its composer is a single-line input that never resizes the list. Ours is multi-line: clearing it + // grows the thread's viewport, and sending in the same tick makes that growth and the new row one + // change for the list to resolve its end against. Doing it in their order left every send short — + // 8 of 8 at one, two and six lines. So clear first, and let that land before the row arrives. injectText('', true) requestAnimationFrame(() => { sendComposerText(text) diff --git a/shared/chat/conversation/list-area/index.tsx b/shared/chat/conversation/list-area/index.tsx index d13783315246..ad88819e5efb 100644 --- a/shared/chat/conversation/list-area/index.tsx +++ b/shared/chat/conversation/list-area/index.tsx @@ -254,10 +254,10 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() { return false }, []) - // Imperative scroll for ThreadRefsContext: for coming back from somewhere else in the thread. When - // the list is already at the end, maintainScrollAtEnd owns that and scrolling here only gets in its - // way — the target resolves before the new row has measured, so it lands short, and while it counts - // as in flight the list declines its own end anchor and abandons it. + // Imperative scroll for ThreadRefsContext: for coming back from somewhere else in the thread, which + // is the only case that needs it. While the list is at the end maintainScrollAtEnd owns the position, + // and scrolling here only displaces it — the target resolves before the new row has measured, so it + // lands short, and while it counts as in flight the list declines its own end anchor and abandons it. const scrollToBottom = React.useCallback(() => { if (isScrolledToEnd()) return void listRef.current?.scrollToEnd({animated: false}) @@ -562,10 +562,10 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() { maintainScrollAtEnd={ centeredOrdinal !== undefined ? false - : // layout included deliberately: naming any trigger opts out of every trigger left - // unnamed, and without it the thread loses the end whenever this viewport shrinks — - // shrinking the window by 200px left it 200px short of the newest message. - {on: {dataChange: true, footerLayout: true, itemLayout: true, layout: true}} + : // The documented form, which enables every trigger. It was a narrowed {on: {...}} list + // before, and naming any trigger opts out of the ones left unnamed — that is how the + // layout trigger went missing and a window resize lost the end. + true } // Stays on while centered: the full thread response lands after the cached one and // re-measures rows above the target, which slides it out of view unless anchored. From f66fd7cb657ebeb8e17c221dce23d1566123d123 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 17 Aug 2026 18:02:48 -0400 Subject: [PATCH 10/11] fix(chat): send on a timeout, not a frame; make the e2e catch multi-line sends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on the send path, two of them correctness bugs the scrolling work introduced. The deferred send held the only copy of the message text while the composer had already been cleared synchronously, and it held it in a requestAnimationFrame callback. Frames stop in a hidden, minimised or backgrounded window, so pressing Enter and immediately hiding the window dropped the message: gone from the composer, gone from the draft, never sent, no error. A timeout runs in all of those cases. It also shortens the window in which the trailing clear from `afterSend` could wipe a keystroke typed right after Enter, from a frame to a task. `jumpToRecent` also moved back after the send. It clears the validated ordinal range, and running it first left the send reading a degraded clientPrev hint on a search-centred thread. The deferral itself stays, and is still load-bearing on 3.3.6 with maintainScrollAtEnd={true}: sending in the same tick puts 16-line messages 8 of 8 short. Measured on the timeout version — sends landing within 2px of the end 10/10 at one, two, six and sixteen lines, returning from 1500px away 4 of 4, nudged 5 to 60px off the end 4 of 4, window resize pinned, hover bar clear. The e2e flow could not catch any of this. It polled until the first reading that looked right, which accepts exactly the late-settling failures this branch chased, and it only ever sent single-line messages, which do not exercise the composer collapse at all — it passed against a deliberately broken same-tick send. It now waits for two equal readings before asserting, shares the app's 2px tolerance instead of its own 8px, and sends a twelve-line message. Twelve because the shortfall scales with the collapse and the shorter ones fail only intermittently. Against the same-tick mutation it now fails, 221.5px short. Also notes that the 8px gap is a deliberate halving rather than a leftover. --- .../conversation/input-area/normal/index.tsx | 27 ++++++---- shared/chat/conversation/list-area/index.tsx | 3 +- .../electron/flows/chat-send-message.test.ts | 50 ++++++++++++++++++- 3 files changed, 66 insertions(+), 14 deletions(-) diff --git a/shared/chat/conversation/input-area/normal/index.tsx b/shared/chat/conversation/input-area/normal/index.tsx index 382ca18726e5..4201177d775a 100644 --- a/shared/chat/conversation/input-area/normal/index.tsx +++ b/shared/chat/conversation/input-area/normal/index.tsx @@ -198,19 +198,24 @@ const ConnectedPlatformInput = function ConnectedPlatformInput() { const {scrollToBottom} = React.useContext(ThreadRefsContext) const onSubmit = (text: string) => { if (!text) return - // legend-list's own chat example appends and calls scrollToEnd in one go, which works there because - // its composer is a single-line input that never resizes the list. Ours is multi-line: clearing it - // grows the thread's viewport, and sending in the same tick makes that growth and the new row one - // change for the list to resolve its end against. Doing it in their order left every send short — - // 8 of 8 at one, two and six lines. So clear first, and let that land before the row arrives. + // Clearing the composer shrinks it back to one line, which grows the thread's viewport. Sending in + // the same tick makes that growth and the new row a single change for the list to resolve its end + // against, and it lands short — 8 of 8 at one, two and six lines, worse the longer the message. So + // clear first and let that land before the row arrives. legend-list's own chat example does both at + // once, which works there because its composer is a single-line input that never resizes the list. + // + // A timeout rather than requestAnimationFrame: this callback owns the only copy of the text, and + // frames stop in a hidden or backgrounded window, which would drop the message with the composer + // already emptied. injectText('', true) - requestAnimationFrame(() => { + setTimeout(() => { sendComposerText(text) - }) - if (hasCenter) { - toggleThreadSearch(true) - jumpToRecent() - } else { + if (hasCenter) { + toggleThreadSearch(true) + jumpToRecent() + } + }, 0) + if (!hasCenter) { scrollToBottom() } } diff --git a/shared/chat/conversation/list-area/index.tsx b/shared/chat/conversation/list-area/index.tsx index ad88819e5efb..d36a61fb3f1b 100644 --- a/shared/chat/conversation/list-area/index.tsx +++ b/shared/chat/conversation/list-area/index.tsx @@ -592,7 +592,8 @@ const useDesktopStyles = Kb.Styles.createStyleHook( // The gap above the input lives out here, not as the list's own paddingBottom: the list // feeds its padding into every scroll-offset calculation it makes (content size, the end // target, the at-end threshold), so keeping it outside the scroller keeps that math on - // message sizes alone. + // message sizes alone. Deliberately 8 rather than the 16 it used to be — half the gap reads + // better with the messages sitting closer to the composer. paddingBottom: 8, }, }), diff --git a/shared/tests/e2e/electron/flows/chat-send-message.test.ts b/shared/tests/e2e/electron/flows/chat-send-message.test.ts index b84fcd8c9a10..1cadc95cabb0 100644 --- a/shared/tests/e2e/electron/flows/chat-send-message.test.ts +++ b/shared/tests/e2e/electron/flows/chat-send-message.test.ts @@ -2,6 +2,10 @@ import {test, expect} from '@/tests/e2e/electron/helpers/fixtures' import {navigateToChat} from '@/tests/e2e/electron/helpers/navigate' import {CHAT_INBOX_ROW, CHAT_MESSAGE_LIST, CHAT_INPUT} from '@/tests/e2e/shared/test-ids' +// The same tolerance the thread itself treats as at-end (list-area's endTolerancePx), so a pass here +// means what the app means by it. +const endTolerancePx = 2 + test('send a message to KB_SMOKE_USER', async ({page}, testInfo) => { testInfo.annotations.push({type: 'account', description: process.env['KB_SMOKE_USER']!}) @@ -68,9 +72,51 @@ test('send a message to KB_SMOKE_USER', async ({page}, testInfo) => { await input.click() await input.fill(`e2e-stick-${Date.now()}-${i}`) await input.press('Enter') + // Waits for the list to come to rest rather than for the first reading that looks right: the + // failures this covers are late ones, where a row re-measures after the list already reported + // itself at the end, so polling until the first pass would accept exactly the bug. await expect - .poll(distanceFromEnd, {message: `send ${i} left the list short of the end`, timeout: 5_000}) - .toBeLessThanOrEqual(8) + .poll( + async () => { + const first = await distanceFromEnd() + await page.waitForTimeout(250) + const second = await distanceFromEnd() + return first === second ? second : undefined + }, + {message: `send ${i} did not settle at the end`, timeout: 8_000} + ) + .toBeLessThanOrEqual(endTolerancePx) } + + // A multi-line message is its own case, and the sharper one: composing it shrinks the thread's + // viewport a line at a time and sending it grows that back, so the collapse and the new row land + // together unless the send waits for the collapse. A single-line send never exercises that. + await test.step('a multi-line send settles at the end too', async () => { + const stamp = `e2e-stick-multi-${Date.now()}` + await input.click() + // Twelve lines, not two or six: the shortfall scales with how much the composer collapses, and + // the short ones only fail intermittently, which would make this a flaky canary rather than a + // failing one. + for (let line = 0; line < 12; line++) { + await page.keyboard.type(`${stamp} line ${line}`) + if (line < 11) { + await page.keyboard.down('Shift') + await page.keyboard.press('Enter') + await page.keyboard.up('Shift') + } + } + await page.keyboard.press('Enter') + await expect + .poll( + async () => { + const first = await distanceFromEnd() + await page.waitForTimeout(250) + const second = await distanceFromEnd() + return first === second ? second : undefined + }, + {message: 'a multi-line send did not settle at the end', timeout: 8_000} + ) + .toBeLessThanOrEqual(endTolerancePx) + }) }) }) From 3398c0d41801396ab9245e668f32f0808ce9ffa0 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 17 Aug 2026 18:09:42 -0400 Subject: [PATCH 11/11] fix(chat): bottom-align short desktop threads with alignItemsAtEnd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A thread with less content than the viewport rendered from the top with the empty space below it. legend-list has `alignItemsAtEnd` for exactly this and we never set it — `alignItemsAtEndPaddingEnabled` was false, so the spacer it inserts was never rendered. It was only ever passed to the native list, added in the Legend List port and removed again when native went back to FlatList; the desktop list never had it despite that commit message claiming they matched. Inert for any thread taller than the viewport, since the padding it adds is max(0, viewport - content - insetEnd). NOT measured, on request: whether short threads now bottom-align in practice, and whether this disturbs sending on one. Both are worth an eye. The padding feeds getContentSize, and finishMaintainScrollAtEnd issues a requestAdjust whenever it changes — which is what happens as a short thread grows toward filling the viewport, i.e. while sending into one. The content also includes SpecialTopMessage, so on a new conversation the NewChatCard may already fill the viewport and leave nothing to pad. --- shared/chat/conversation/list-area/index.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/shared/chat/conversation/list-area/index.tsx b/shared/chat/conversation/list-area/index.tsx index d36a61fb3f1b..dda21755fdf5 100644 --- a/shared/chat/conversation/list-area/index.tsx +++ b/shared/chat/conversation/list-area/index.tsx @@ -557,6 +557,9 @@ const DesktopThreadWrapper = function DesktopThreadWrapper() { drawDistance={250} estimatedItemSize={72} style={Kb.Styles.castStyleDesktop(desktopStyles.list)} + // Short threads sit at the bottom rather than the top. Inert once the content is taller than + // the viewport: the padding it adds is max(0, viewport - content). + alignItemsAtEnd={true} initialScrollAtEnd={initialScrollIndex === undefined} initialScrollIndex={initialScrollIndex} maintainScrollAtEnd={