authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-08-29 16:46:54-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-29 16:46:54-07:00
log1f8ed54add4133ac39effceded8fc1fff12d8e03
tree0d4a1445f2e01050a7ce6d53e0b114b0bc0ea6b4
parent3d32797f6f4614205debf8a61f09e950f5f9a81c
signature Signed by PGP key B5690EEEBB952194

fix(mobile): reduce dev-client reload and Metro startup cost (#8694)

Co-authored-by: Julius Marminge <julius@mac.lan>

31 files changed, 1800 insertions(+), 460 deletions(-)

.agents/skills/test-t3-mobile/SKILL.md+1-1
......@@ -80,7 +80,6 @@ Run Metro from `apps/mobile`.
8080 APP_VARIANT=development vp exec expo start \
8181 --dev-client \
8282 --scheme t3code-dev \
83 --clear \
8483 --lan \
8584 --port <metro-port>
8685 ```
......@@ -179,6 +178,7 @@ Keep local verification focused. Do not turn this workflow into a full repositor
179178## Troubleshoot predictable failures
180179
181180- **Old UI or an old error appears:** verify Metro's worktree, variant, URL, and port before diagnosing the app.
181- **Metro serves stale or invalid transforms after those checks:** stop the owned Metro process and run `vp run dev:client:reset` once on the standard port. For a custom port, add `--clear` to the complete explicit `expo start` command above.
182182- **The environment remains empty:** verify the platform-specific HTTP origin, use a fresh token, and confirm project seeding used the identical base directory.
183183- **A second client cannot pair:** pairing tokens are single-use; issue another token.
184184- **The pairing form opens but does not connect:** confirm the deep link uses the existing `connections/new` route, includes `autoConnect=1`, and carries a freshly minted encoded `pairingUrl`.
AGENTS.md+1
......@@ -104,6 +104,7 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real
104104## Verifying
105105
106106- Smallest proof that the change works. `vp test run <files>` for the tests you touched, targeted lint and typecheck for the scope you changed.
107- Test meaningful logic or observable behavior. Do not render components to static markup to assert props or attributes, or add tests that merely assert callback wiring or mirror the implementation.
107108- **Do not run repo-wide checks.** No `vp check`, no `vp run -r test`, no `vp run -r typecheck` unless I ask. CI owns the full suite.
108109- Backend behavior changes ship with focused tests for that behavior.
109110- The server is event-sourced and its async flows emit typed receipts. Wait on receipts and worker drains, never on sleeps or polling. A test that needs a timeout to pass is wrong.
apps/mobile/README.md+20-1
......@@ -28,6 +28,23 @@ Start Metro for the dev client:
2828vp run dev:client
2929```
3030
31Metro keeps its transform cache between ordinary starts. If the cache itself is causing stale or
32invalid output, clear it for one development-client start:
33
34```bash
35vp run dev:client:reset
36```
37
38Run that reset once after installing or changing the Uniwind dependency patch. Cached transforms
39can otherwise reference its previous pnpm package path. Ordinary Metro starts still keep the cache.
40
41Component edits use Fast Refresh. Connection-runtime edits replace the active Effect layer through
42a stable atom runtime, preserving navigation and existing atom subscribers. Replaced registries
43and managed runtimes dispose their resources; the app does not force a JavaScript reload. The Uniwind patch
44skips global style invalidation when generated styles and themes are unchanged, while real style
45changes still refresh. See [mobile development lifecycle](../../docs/internals/mobile-development.md)
46for the lifetime boundaries.
47
3148Build and run the local iOS dev client:
3249
3350```bash
......@@ -89,7 +106,9 @@ The native lint task runs SwiftLint for Swift plus ktlint and detekt for Kotlin.
89106
90107## EAS Builds
91108
92CI uses Expo fingerprinting with the `preview:dev` profile to reuse an existing compatible build when possible, or start a new internal EAS build when native runtime inputs change. Production and default local builds continue to use the `appVersion` runtime policy.
109Preview and production variants use Expo fingerprinting so OTA updates only reach binaries with matching native dependencies, config plugins, and patches. CI uses the `preview:dev` profile to reuse a compatible native build when possible.
110
111The development variant uses `appVersion` to avoid recalculating the native fingerprint for each Metro launch manifest. `MOBILE_VERSION_POLICY` can override either default. If you distribute a custom Release build with the development identity and publish OTA updates to it, set `MOBILE_VERSION_POLICY=fingerprint` for both its build and updates. Changing the runtime policy requires a native rebuild for OTA matching; an existing dev client can still load local Metro bundles.
93112
94113For preview or production EAS environments, set `T3CODE_CLERK_PUBLISHABLE_KEY`,
95114`T3CODE_CLERK_JWT_TEMPLATE`, and `T3CODE_RELAY_URL`
apps/mobile/app.config.ts+7-5
......@@ -10,6 +10,9 @@ Object.assign(process.env, repoEnv);
1010
1111const APP_VARIANT = resolveAppVariant(repoEnv.APP_VARIANT);
1212const isIosPersonalTeamBuild = repoEnv.T3CODE_IOS_PERSONAL_TEAM === "1";
13const runtimeVersionPolicy =
14 process.env.MOBILE_VERSION_POLICY ??
15 (APP_VARIANT === "development" ? "appVersion" : "fingerprint");
1316
1417const personalTeamBundleIdentifier = repoEnv.T3CODE_IOS_PERSONAL_TEAM_BUNDLE_ID?.trim();
1518const IOS_BUNDLE_IDENTIFIER_PATTERN = /^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/;
......@@ -166,11 +169,10 @@ const config: ExpoConfig = {
166169 scheme: variant.scheme,
167170 version: "1.0.4",
168171 runtimeVersion: {
169 // Fingerprint (not appVersion) so an OTA only reaches binaries whose native
170 // project — native deps, config plugins, AND patches/ — matches the update.
171 // With appVersion, every 0.1.0 build shares a runtime version, so a JS update
172 // could land on a binary missing the native changes it needs and crash.
173 policy: process.env.MOBILE_VERSION_POLICY ?? "fingerprint",
172 // Development manifests resolve on every launch, so avoid fingerprint's
173 // expensive native-project calculation there. Preview and production stay
174 // fingerprinted so OTAs only reach binaries with matching native projects.
175 policy: runtimeVersionPolicy,
174176 },
175177 orientation: "portrait",
176178 icon: variant.assets.appIcon,
apps/mobile/package.json+6-5
......@@ -4,14 +4,15 @@
44 "private": true,
55 "main": "index.ts",
66 "scripts": {
7 "dev": "expo start --clear",
8 "dev:client": "APP_VARIANT=development expo start --dev-client --scheme t3code-dev --clear --lan",
9 "dev:client:preview": "eas env:exec preview 'EXPO_NO_DOTENV=1 APP_VARIANT=preview expo start --dev-client --scheme t3code-preview --clear --lan'",
7 "dev": "expo start",
8 "dev:client": "APP_VARIANT=development expo start --dev-client --scheme t3code-dev --lan",
9 "dev:client:reset": "APP_VARIANT=development expo start --dev-client --scheme t3code-dev --clear --lan",
10 "dev:client:preview": "eas env:exec preview 'EXPO_NO_DOTENV=1 APP_VARIANT=preview expo start --dev-client --scheme t3code-preview --lan'",
1011 "start": "expo start",
1112 "start:dev": "APP_VARIANT=development expo start",
1213 "start:preview": "APP_VARIANT=preview expo start",
1314 "start:prod": "APP_VARIANT=production expo start",
14 "showcase": "APP_VARIANT=production EXPO_PUBLIC_SHOWCASE=1 expo start --dev-client --scheme t3code --clear",
15 "showcase": "APP_VARIANT=production EXPO_PUBLIC_SHOWCASE=1 expo start --dev-client --scheme t3code",
1516 "screenshots": "node ../../scripts/mobile-showcase.ts",
1617 "android": "EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android",
1718 "android:dev": "APP_VARIANT=development EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && REACT_NATIVE_PACKAGER_HOSTNAME=localhost expo run:android",
......@@ -120,7 +121,7 @@
120121 "react-native-worklets": "0.10.1",
121122 "shiki": "4.2.0",
122123 "tailwind-merge": "^3.5.0",
123 "uniwind": "^1.6.2"
124 "uniwind": "1.11.0"
124125 },
125126 "devDependencies": {
126127 "@effect/vitest": "catalog:",
apps/mobile/src/components/AppSymbol.ios.tsx created+15
......@@ -0,0 +1,15 @@
1import { SymbolView as ExpoSymbolView, type SymbolViewProps } from "expo-symbols";
2import { withUniwind } from "uniwind";
3
4export type { SFSymbol } from "expo-symbols";
5export type AppSymbolName = SymbolViewProps["name"];
6
7/**
8 * Keep the iOS implementation isolated from the Android Tabler fallback so
9 * Metro does not initialize the icon package when iOS renders SF Symbols.
10 */
11function AppSymbolView(props: SymbolViewProps) {
12 return <ExpoSymbolView {...props} />;
13}
14
15export const SymbolView = withUniwind(AppSymbolView);
apps/mobile/src/components/AppSymbol.tabler.d.ts created+7
......@@ -0,0 +1,7 @@
1// Tabler 3.44 exports per-icon runtime modules but points their declarations at missing files.
2declare module "@tabler/icons-react-native/Icon*" {
3 import type { Icon } from "@tabler/icons-react-native";
4
5 const icon: Icon;
6 export default icon;
7}
apps/mobile/src/components/AppSymbol.tsx+84-87
......@@ -1,86 +1,87 @@
1import {
2 IconAdjustmentsHorizontal,
3 IconAlertCircle,
4 IconAlertTriangle,
5 IconApps,
6 IconArchive,
7 IconArrowBackUp,
8 IconArrowDownCircle,
9 IconArrowRightCircle,
10 IconArrowUp,
11 IconArrowUpCircle,
12 IconArrowUpRight,
13 IconArrowUpRightCircle,
14 IconArrowsMaximize,
15 IconBellRinging,
16 IconBolt,
17 IconBox,
18 IconCamera,
19 IconChartBar,
20 IconCheck,
21 IconChevronDown,
22 IconCode,
23 IconChevronLeft,
24 IconChevronRight,
25 IconChevronUp,
26 IconCircleCheck,
27 IconCircleXFilled,
28 IconClock,
29 IconCopy,
30 IconDeviceDesktop,
31 IconDots,
32 IconDotsCircleHorizontal,
33 IconEdit,
34 IconExternalLink,
35 IconEye,
36 IconFileText,
37 IconFilter,
38 IconFolder,
39 IconFolderOpen,
40 IconFolderPlus,
41 IconGitBranch,
42 IconHammer,
43 IconGitMerge,
44 IconGitPullRequest,
45 IconInfoCircle,
46 IconKeyboard,
47 IconKeyboardHide,
48 IconLayoutColumns,
49 IconLayoutSidebar,
50 IconLetterSpacing,
51 IconLink,
52 IconMessage,
53 IconMinus,
54 IconMoon,
55 IconNetwork,
56 IconPalette,
57 IconPin,
58 IconPinnedOff,
59 IconPlayerPlay,
60 IconPlayerStopFilled,
61 IconPlus,
62 IconQrcode,
63 IconRefresh,
64 IconSearch,
65 IconServer,
66 IconSettings,
67 IconSparkles,
68 IconSun,
69 IconLayoutSidebarRight,
70 IconTerminal2,
71 IconTextDecrease,
72 IconTextIncrease,
73 IconTool,
74 IconTrash,
75 IconTypography,
76 IconUserCircle,
77 IconWifiOff,
78 IconWorld,
79 IconX,
80 type Icon,
81} from "@tabler/icons-react-native";
82import { Platform } from "react-native";
83import { SymbolView as ExpoSymbolView, type SFSymbol, type SymbolViewProps } from "expo-symbols";
1import type { Icon } from "@tabler/icons-react-native/types";
2/*
3 * Keep these as per-icon exports. Importing the package root eagerly registers
4 * the entire Tabler icon set in Metro.
5 */
6import IconAdjustmentsHorizontal from "@tabler/icons-react-native/IconAdjustmentsHorizontal";
7import IconAlertCircle from "@tabler/icons-react-native/IconAlertCircle";
8import IconAlertTriangle from "@tabler/icons-react-native/IconAlertTriangle";
9import IconApps from "@tabler/icons-react-native/IconApps";
10import IconArchive from "@tabler/icons-react-native/IconArchive";
11import IconArrowBackUp from "@tabler/icons-react-native/IconArrowBackUp";
12import IconArrowDownCircle from "@tabler/icons-react-native/IconArrowDownCircle";
13import IconArrowRightCircle from "@tabler/icons-react-native/IconArrowRightCircle";
14import IconArrowUp from "@tabler/icons-react-native/IconArrowUp";
15import IconArrowUpCircle from "@tabler/icons-react-native/IconArrowUpCircle";
16import IconArrowUpRight from "@tabler/icons-react-native/IconArrowUpRight";
17import IconArrowUpRightCircle from "@tabler/icons-react-native/IconArrowUpRightCircle";
18import IconArrowsMaximize from "@tabler/icons-react-native/IconArrowsMaximize";
19import IconBellRinging from "@tabler/icons-react-native/IconBellRinging";
20import IconBolt from "@tabler/icons-react-native/IconBolt";
21import IconBox from "@tabler/icons-react-native/IconBox";
22import IconCamera from "@tabler/icons-react-native/IconCamera";
23import IconChartBar from "@tabler/icons-react-native/IconChartBar";
24import IconCheck from "@tabler/icons-react-native/IconCheck";
25import IconChevronDown from "@tabler/icons-react-native/IconChevronDown";
26import IconChevronLeft from "@tabler/icons-react-native/IconChevronLeft";
27import IconChevronRight from "@tabler/icons-react-native/IconChevronRight";
28import IconChevronUp from "@tabler/icons-react-native/IconChevronUp";
29import IconCircleCheck from "@tabler/icons-react-native/IconCircleCheck";
30import IconCircleXFilled from "@tabler/icons-react-native/IconCircleXFilled";
31import IconClock from "@tabler/icons-react-native/IconClock";
32import IconCode from "@tabler/icons-react-native/IconCode";
33import IconCopy from "@tabler/icons-react-native/IconCopy";
34import IconDeviceDesktop from "@tabler/icons-react-native/IconDeviceDesktop";
35import IconDots from "@tabler/icons-react-native/IconDots";
36import IconDotsCircleHorizontal from "@tabler/icons-react-native/IconDotsCircleHorizontal";
37import IconEdit from "@tabler/icons-react-native/IconEdit";
38import IconExternalLink from "@tabler/icons-react-native/IconExternalLink";
39import IconEye from "@tabler/icons-react-native/IconEye";
40import IconFileText from "@tabler/icons-react-native/IconFileText";
41import IconFilter from "@tabler/icons-react-native/IconFilter";
42import IconFolder from "@tabler/icons-react-native/IconFolder";
43import IconFolderOpen from "@tabler/icons-react-native/IconFolderOpen";
44import IconFolderPlus from "@tabler/icons-react-native/IconFolderPlus";
45import IconGitBranch from "@tabler/icons-react-native/IconGitBranch";
46import IconGitMerge from "@tabler/icons-react-native/IconGitMerge";
47import IconGitPullRequest from "@tabler/icons-react-native/IconGitPullRequest";
48import IconHammer from "@tabler/icons-react-native/IconHammer";
49import IconInfoCircle from "@tabler/icons-react-native/IconInfoCircle";
50import IconKeyboard from "@tabler/icons-react-native/IconKeyboard";
51import IconKeyboardHide from "@tabler/icons-react-native/IconKeyboardHide";
52import IconLayoutColumns from "@tabler/icons-react-native/IconLayoutColumns";
53import IconLayoutSidebar from "@tabler/icons-react-native/IconLayoutSidebar";
54import IconLayoutSidebarRight from "@tabler/icons-react-native/IconLayoutSidebarRight";
55import IconLetterSpacing from "@tabler/icons-react-native/IconLetterSpacing";
56import IconLink from "@tabler/icons-react-native/IconLink";
57import IconMessage from "@tabler/icons-react-native/IconMessage";
58import IconMinus from "@tabler/icons-react-native/IconMinus";
59import IconMoon from "@tabler/icons-react-native/IconMoon";
60import IconNetwork from "@tabler/icons-react-native/IconNetwork";
61import IconPalette from "@tabler/icons-react-native/IconPalette";
62import IconPin from "@tabler/icons-react-native/IconPin";
63import IconPinnedOff from "@tabler/icons-react-native/IconPinnedOff";
64import IconPlayerPlay from "@tabler/icons-react-native/IconPlayerPlay";
65import IconPlayerStopFilled from "@tabler/icons-react-native/IconPlayerStopFilled";
66import IconPlus from "@tabler/icons-react-native/IconPlus";
67import IconQrcode from "@tabler/icons-react-native/IconQrcode";
68import IconRefresh from "@tabler/icons-react-native/IconRefresh";
69import IconSearch from "@tabler/icons-react-native/IconSearch";
70import IconServer from "@tabler/icons-react-native/IconServer";
71import IconSettings from "@tabler/icons-react-native/IconSettings";
72import IconSparkles from "@tabler/icons-react-native/IconSparkles";
73import IconSun from "@tabler/icons-react-native/IconSun";
74import IconTerminal2 from "@tabler/icons-react-native/IconTerminal2";
75import IconTextDecrease from "@tabler/icons-react-native/IconTextDecrease";
76import IconTextIncrease from "@tabler/icons-react-native/IconTextIncrease";
77import IconTool from "@tabler/icons-react-native/IconTool";
78import IconTrash from "@tabler/icons-react-native/IconTrash";
79import IconTypography from "@tabler/icons-react-native/IconTypography";
80import IconUserCircle from "@tabler/icons-react-native/IconUserCircle";
81import IconWifiOff from "@tabler/icons-react-native/IconWifiOff";
82import IconWorld from "@tabler/icons-react-native/IconWorld";
83import IconX from "@tabler/icons-react-native/IconX";
84import type { SFSymbol, SymbolViewProps } from "expo-symbols";
8485import { withUniwind } from "uniwind";
8586
8687const ANDROID_ICON_BY_SF_SYMBOL: Partial<Record<SFSymbol, Icon>> = {
......@@ -192,10 +193,6 @@ export type { SFSymbol } from "expo-symbols";
192193export type AppSymbolName = SymbolViewProps["name"];
193194
194195function AppSymbolView(props: SymbolViewProps) {
195 if (Platform.OS !== "android") {
196 return <ExpoSymbolView {...props} />;
197 }
198
199196 const materialName = typeof props.name === "string" ? undefined : props.name.android;
200197 const sfSymbol = typeof props.name === "string" ? props.name : props.name.ios;
201198 const AndroidIcon =
apps/mobile/src/components/ControlPill.tsx+52-12
......@@ -8,7 +8,7 @@ import {
88 type ReactNode,
99 useRef,
1010} from "react";
11import { Platform, Pressable, View } from "react-native";
11import { Platform, Pressable, View, type PressableProps } from "react-native";
1212import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider";
1313
1414import { cn } from "../lib/cn";
......@@ -120,6 +120,8 @@ export function ControlPillMenu(
120120) {
121121 const { themeAppearance } = useAppearancePreferences();
122122 const isDarkMode = themeAppearance === "dark";
123 const menuPress = useRef({ isPreparing: false, isOpen: false, suppressPress: false });
124 const pendingPress = useRef<(() => void) | null>(null);
123125
124126 if (Platform.OS === "android") {
125127 // Long-press menus keep their child interactive: the child element gets
......@@ -161,20 +163,58 @@ export function ControlPillMenu(
161163
162164 const { className: _className, ...menuProps } = props;
163165 let children = menuProps.children;
164 // In long-press mode the wrapped pressable still receives the touch (the
165 // patched MenuView button is touch-transparent) and RN's Fabric touch
166 // handler is never cancelled by the in-tree UIContextMenuInteraction, so a
167 // bare onPress would fire on finger-up even after the menu opened — and
168 // also on a long press released just under the menu threshold. A dispatched
169 // onLongPress makes Pressability swallow the release, so holds past 350ms
170 // (below the ~500ms context-menu threshold) can only open the menu, never
171 // tap through.
172166 if (props.shouldOpenOnLongPress && isValidElement(children)) {
173 const child = children as ReactElement<{ onLongPress?: () => void; delayLongPress?: number }>;
167 const child = children as ReactElement<Pick<PressableProps, "onTouchStart" | "onPress">>;
174168 children = cloneElement(child, {
175 onLongPress: child.props.onLongPress ?? (() => undefined),
176 delayLongPress: child.props.delayLongPress ?? 350,
169 onTouchStart: (event) => {
170 // Reset for a new touch, not onPressIn, which also fires when a
171 // finger moves out of the row and back during the same gesture.
172 menuPress.current.isPreparing = false;
173 menuPress.current.suppressPress = menuPress.current.isOpen;
174 pendingPress.current = null;
175 child.props.onTouchStart?.(event);
176 },
177 onPress: (event) => {
178 // Accessibility clicks have no touch identifier and must not inherit
179 // cancellation from a previous physical gesture.
180 const isTouch = typeof event.nativeEvent.identifier === "number";
181 if (isTouch ? menuPress.current.suppressPress : menuPress.current.isOpen) {
182 return;
183 }
184 if (isTouch && menuPress.current.isPreparing) {
185 // A release can arrive between native menu preparation and display.
186 // Let UIKit's display/cancel callback decide this press's outcome.
187 event.persist();
188 pendingPress.current = () => child.props.onPress?.(event);
189 return;
190 }
191 child.props.onPress?.(event);
192 },
177193 });
194 menuProps.onMenuInteractionStart = () => {
195 menuPress.current.isPreparing = true;
196 props.onMenuInteractionStart?.();
197 };
198 menuProps.onOpenMenu = () => {
199 menuPress.current.isPreparing = false;
200 menuPress.current.isOpen = true;
201 menuPress.current.suppressPress = true;
202 pendingPress.current = null;
203 props.onOpenMenu?.();
204 };
205 menuProps.onCloseMenu = () => {
206 menuPress.current.isPreparing = false;
207 menuPress.current.isOpen = false;
208 // Keep this gesture cancelled even if dismissal precedes finger-up.
209 // A separate JS long-press timer would also swallow holds that never
210 // open the native menu.
211 const press = pendingPress.current;
212 pendingPress.current = null;
213 props.onCloseMenu?.();
214 if (!menuPress.current.suppressPress) {
215 press?.();
216 }
217 };
178218 }
179219 return (
180220 <MenuView {...menuProps} themeVariant={isDarkMode ? "dark" : "light"}>
apps/mobile/src/connection/runtime.ts+11-1
......@@ -4,13 +4,18 @@ import { threadSnapshotLoaderLayer } from "@t3tools/client-runtime/state/threads
44import * as Layer from "effect/Layer";
55import { Atom } from "effect/unstable/reactivity";
66
7import type { FoundationHotModule } from "../lib/foundation-fast-refresh";
8import { hotSwappableAtomRuntime } from "../lib/hot-swappable-atom-runtime";
79import { runtimeContextLayer } from "../lib/runtime";
10import { appAtomRegistry } from "../state/atom-registry";
811import {
912 mobileBackgroundActivityObserverLayer,
1013 mobileBackgroundActivityReporterLayer,
1114} from "./background-activity";
1215import { connectionPlatformLayer } from "./platform";
1316
17declare const module: { readonly hot?: FoundationHotModule } | undefined;
18
1419const providedConnectionPlatformLayer = connectionPlatformLayer.pipe(
1520 Layer.provide(runtimeContextLayer),
1621);
......@@ -42,4 +47,9 @@ const connectionLayer = mobileBackgroundActivityReporterLayer.pipe(
4247export const connectionAtomRuntime: Atom.AtomRuntime<
4348 Layer.Success<ConnectionLayerSource>,
4449 Layer.Error<ConnectionLayerSource>
45> = Atom.runtime(connectionLayer);
50> = hotSwappableAtomRuntime({
51 id: "t3.mobile.connection-runtime",
52 hotModule: typeof module === "undefined" ? undefined : module.hot,
53 registry: appAtomRegistry,
54 layer: connectionLayer,
55});
apps/mobile/src/features/home/HomeRouteScreen.tsx+3-8
......@@ -17,6 +17,7 @@ import { AndroidHomeFabLayout } from "./AndroidHomeFab";
1717import { HomeScreen } from "./HomeScreen";
1818import { HomeHeader } from "./HomeHeader";
1919import { useHomeListOptions } from "./home-list-options";
20import { useHomeThreadSelection } from "./home-thread-navigation";
2021import { buildHomeProjectScopes } from "./homeThreadList";
2122import { usePendingTaskListActions } from "./usePendingTaskListActions";
2223import { useThreadListActions } from "./useThreadListActions";
......@@ -33,6 +34,7 @@ export function HomeRouteScreen() {
3334 const { savedConnectionsById } = useSavedRemoteConnections();
3435 const navigation = useNavigation();
3536 const [searchQuery, setSearchQuery] = useState("");
37 const handleSelectThread = useHomeThreadSelection();
3638
3739 useEffect(() => {
3840 void checkForAppUpdateOnLaunch();
......@@ -209,14 +211,7 @@ export function HomeRouteScreen() {
209211 }
210212 onProjectSortOrderChange={setProjectSortOrder}
211213 onSearchQueryChange={setSearchQuery}
212 onSelectThread={(thread) => {
213 // Settled threads are live shells: opening one is plain
214 // navigation, and sending a message un-settles server-side.
215 navigation.navigate("Thread", {
216 environmentId: thread.environmentId,
217 threadId: thread.id,
218 });
219 }}
214 onSelectThread={handleSelectThread}
220215 onSelectPendingTask={openPendingTask}
221216 onDeletePendingTask={confirmDeletePendingTask}
222217 onNewThreadInProject={(project) => {
apps/mobile/src/features/home/HomeScreen.tsx+11-9
......@@ -19,6 +19,7 @@ import type {
1919} from "@t3tools/contracts";
2020import { useAtomSet, useAtomValue } from "@effect/atom-react";
2121import { AsyncResult } from "effect/unstable/reactivity";
22import { useFocusEffect } from "@react-navigation/native";
2223import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2324import { ActivityIndicator, FlatList, Platform, Pressable, View } from "react-native";
2425import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable";
......@@ -587,15 +588,16 @@ export function HomeScreen(props: HomeScreenProps) {
587588 // next wake boundary re-runs the partition with a fresh clock so a woken
588589 // thread reappears immediately instead of on the next minute tick.
589590 const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0);
590 useEffect(() => {
591 if (!threadListV2Enabled) return;
592 // Refresh immediately on enable: the mount-time value can be hours old
593 // by the time the beta is switched on, which would misclassify the
594 // inactivity auto-settle boundary until the first tick.
595 setNowMinute(new Date().toISOString().slice(0, 16));
596 const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000);
597 return () => clearInterval(id);
598 }, [threadListV2Enabled]);
591 useFocusEffect(
592 useCallback(() => {
593 if (!threadListV2Enabled) return;
594 // Refresh immediately on enable or focus: the previous value can be hours
595 // old and misclassify the inactivity auto-settle boundary until the first tick.
596 setNowMinute(new Date().toISOString().slice(0, 16));
597 const id = setInterval(() => setNowMinute(new Date().toISOString().slice(0, 16)), 60_000);
598 return () => clearInterval(id);
599 }, [threadListV2Enabled]),
600 );
599601 // Threads on servers without the settlement capability never classify as
600602 // settled (the user could neither un-settle nor pin them).
601603 const serverConfigs = useAtomValue(environmentServerConfigsAtom);
apps/mobile/src/features/home/home-thread-navigation.test.ts created+196
......@@ -0,0 +1,196 @@
1import * as NodeModule from "node:module";
2import type {
3 StackNavigationState,
4 StackRouter as StackRouterType,
5 StackActions as StackActionsType,
6} from "@react-navigation/native";
7import { describe, expect, it, vi } from "vite-plus/test";
8
9function loadRouters() {
10 const require = NodeModule.createRequire(import.meta.url);
11 const nativePackage = require.resolve("@react-navigation/native/package.json");
12 const requireFromNative = NodeModule.createRequire(nativePackage);
13 const corePackage = requireFromNative.resolve("@react-navigation/core/package.json");
14 const requireFromCore = NodeModule.createRequire(corePackage);
15 return requireFromCore("@react-navigation/routers") as {
16 readonly CommonActions: typeof import("@react-navigation/native").CommonActions;
17 readonly StackActions: typeof StackActionsType;
18 readonly StackRouter: typeof StackRouterType;
19 };
20}
21
22vi.mock("@react-navigation/native", () => {
23 const { CommonActions, StackActions } = loadRouters();
24 return { CommonActions, StackActions };
25});
26
27import { createHomeThreadNavigationAction } from "./home-thread-navigation";
28
29const { StackActions, StackRouter } = loadRouters();
30const routeNames = ["Home", "Thread"];
31const routeParamList = {
32 Home: undefined,
33 Thread: undefined,
34};
35const router = StackRouter({});
36const routerOptions = {
37 routeNames,
38 routeParamList,
39 routeGetIdList: {},
40};
41
42type ThreadSelection = Parameters<typeof createHomeThreadNavigationAction>[0]["thread"];
43
44function thread(id: string): ThreadSelection {
45 return {
46 environmentId: "environment-1",
47 id,
48 } as ThreadSelection;
49}
50
51function initialState() {
52 return router.getInitialState(routerOptions);
53}
54
55function apply(
56 state: StackNavigationState<Record<string, object | undefined>>,
57 action: Parameters<typeof router.getStateForAction>[1],
58) {
59 const nextState = router.getStateForAction(state, action, routerOptions);
60 expect(nextState).not.toBeNull();
61 return nextState as StackNavigationState<Record<string, object | undefined>>;
62}
63
64function selectThread(
65 state: ReturnType<typeof initialState>,
66 selectedThread: ThreadSelection,
67 dismissingRouteKey: string | null = null,
68) {
69 return apply(
70 state,
71 createHomeThreadNavigationAction({
72 state,
73 dismissingRouteKey,
74 thread: selectedThread,
75 }),
76 );
77}
78
79function dismissRoute(state: ReturnType<typeof initialState>, routeKey: string) {
80 return apply(state, {
81 ...StackActions.pop(),
82 source: routeKey,
83 target: state.key,
84 });
85}
86
87describe("createHomeThreadNavigationAction", () => {
88 it("coalesces ordinary repeat selections onto the current thread route", () => {
89 const firstState = selectThread(initialState(), thread("thread-a"));
90 const threadRouteKey = firstState.routes[firstState.index]?.key;
91 const secondAction = createHomeThreadNavigationAction({
92 state: firstState,
93 dismissingRouteKey: null,
94 thread: thread("thread-b"),
95 });
96
97 const secondState = apply(firstState, secondAction);
98 expect(secondState.routes).toHaveLength(2);
99 expect(secondState.routes[secondState.index]).toMatchObject({
100 key: threadRouteKey,
101 name: "Thread",
102 params: { environmentId: "environment-1", threadId: "thread-b" },
103 });
104 });
105
106 it("keeps an overlap selection after native dismisses the outgoing route", () => {
107 const outgoingState = selectThread(initialState(), thread("thread-a"));
108 const outgoingRouteKey = outgoingState.routes[outgoingState.index]?.key;
109 expect(outgoingRouteKey).toBeDefined();
110
111 const overlapAction = createHomeThreadNavigationAction({
112 state: outgoingState,
113 dismissingRouteKey: outgoingRouteKey ?? null,
114 thread: thread("thread-b"),
115 });
116 const overlapState = apply(outgoingState, overlapAction);
117 const incomingRoute = overlapState.routes[overlapState.index];
118 expect(incomingRoute?.key).not.toBe(outgoingRouteKey);
119
120 const dismissedState = dismissRoute(overlapState, outgoingRouteKey!);
121 expect(dismissedState.routes).toHaveLength(2);
122 expect(dismissedState.routes[dismissedState.index]).toMatchObject({
123 key: incomingRoute?.key,
124 name: "Thread",
125 params: { environmentId: "environment-1", threadId: "thread-b" },
126 });
127 });
128
129 it("uses a fresh key for the same thread selected during dismissal", () => {
130 const outgoingState = selectThread(initialState(), thread("thread-a"));
131 const outgoingRouteKey = outgoingState.routes[outgoingState.index]?.key;
132 expect(outgoingRouteKey).toBeDefined();
133
134 const overlapState = selectThread(outgoingState, thread("thread-a"), outgoingRouteKey ?? null);
135 const incomingRouteKey = overlapState.routes[overlapState.index]?.key;
136 expect(incomingRouteKey).not.toBe(outgoingRouteKey);
137
138 const dismissedState = dismissRoute(overlapState, outgoingRouteKey!);
139 expect(dismissedState.routes[dismissedState.index]).toMatchObject({
140 key: incomingRouteKey,
141 params: { environmentId: "environment-1", threadId: "thread-a" },
142 });
143 });
144
145 it("coalesces a second overlap selection onto the fresh incoming route", () => {
146 const outgoingState = selectThread(initialState(), thread("thread-a"));
147 const outgoingRouteKey = outgoingState.routes[outgoingState.index]?.key;
148 expect(outgoingRouteKey).toBeDefined();
149
150 const firstOverlapState = selectThread(
151 outgoingState,
152 thread("thread-b"),
153 outgoingRouteKey ?? null,
154 );
155 const incomingRouteKey = firstOverlapState.routes[firstOverlapState.index]?.key;
156 const secondOverlapAction = createHomeThreadNavigationAction({
157 state: firstOverlapState,
158 dismissingRouteKey: outgoingRouteKey ?? null,
159 thread: thread("thread-c"),
160 });
161
162 const secondOverlapState = apply(firstOverlapState, secondOverlapAction);
163 expect(secondOverlapState.routes).toHaveLength(3);
164 expect(secondOverlapState.routes[secondOverlapState.index]).toMatchObject({
165 key: incomingRouteKey,
166 params: { environmentId: "environment-1", threadId: "thread-c" },
167 });
168
169 const dismissedState = dismissRoute(secondOverlapState, outgoingRouteKey!);
170 expect(dismissedState.routes).toHaveLength(2);
171 expect(dismissedState.routes[dismissedState.index]).toMatchObject({
172 key: incomingRouteKey,
173 params: { environmentId: "environment-1", threadId: "thread-c" },
174 });
175 });
176
177 it("uses ordinary navigation when native pops before the selection", () => {
178 const outgoingState = selectThread(initialState(), thread("thread-a"));
179 const outgoingRouteKey = outgoingState.routes[outgoingState.index]?.key;
180 expect(outgoingRouteKey).toBeDefined();
181
182 const poppedState = dismissRoute(outgoingState, outgoingRouteKey!);
183 const action = createHomeThreadNavigationAction({
184 state: poppedState,
185 dismissingRouteKey: outgoingRouteKey ?? null,
186 thread: thread("thread-b"),
187 });
188
189 const selectedState = apply(poppedState, action);
190 expect(selectedState.routes).toHaveLength(2);
191 expect(selectedState.routes[selectedState.index]).toMatchObject({
192 name: "Thread",
193 params: { environmentId: "environment-1", threadId: "thread-b" },
194 });
195 });
196});
apps/mobile/src/features/home/home-thread-navigation.ts created+71
......@@ -0,0 +1,71 @@
1import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
2import {
3 CommonActions,
4 StackActions,
5 useNavigation,
6 type NavigationState,
7} from "@react-navigation/native";
8import type { NativeStackNavigationProp } from "@react-navigation/native-stack";
9import { useCallback, useEffect, useRef } from "react";
10
11type ThreadSelection = Pick<EnvironmentThreadShell, "environmentId" | "id">;
12
13export function createHomeThreadNavigationAction(input: {
14 readonly state: Pick<NavigationState, "index" | "routes">;
15 readonly dismissingRouteKey: string | null;
16 readonly thread: ThreadSelection;
17}) {
18 const currentRoute = input.state.routes[input.state.index];
19 const params = {
20 environmentId: input.thread.environmentId,
21 threadId: input.thread.id,
22 };
23
24 // Native swipe-back pops the outgoing route after its animation. Reusing
25 // that key would also discard this selection when the dismissal arrives.
26 if (input.dismissingRouteKey !== null && currentRoute?.key === input.dismissingRouteKey) {
27 return StackActions.push("Thread", params);
28 }
29
30 return CommonActions.navigate("Thread", params);
31}
32
33export function useHomeThreadSelection() {
34 const navigation =
35 useNavigation<NativeStackNavigationProp<ReactNavigation.RootParamList, "Home">>();
36 const dismissingRouteKey = useRef<string | null>(null);
37
38 useEffect(() => {
39 const clear = () => {
40 dismissingRouteKey.current = null;
41 };
42 // This listener belongs to Home, so swipe-back is its opening transition.
43 // Thread's closing event is targeted at the outgoing Thread route.
44 const removeTransitionStart = navigation.addListener("transitionStart", ({ data }) => {
45 const state = navigation.getState();
46 const currentRoute = state.routes[state.index];
47 dismissingRouteKey.current =
48 !data.closing && currentRoute?.name === "Thread" ? currentRoute.key : null;
49 });
50 const removeFocus = navigation.addListener("focus", clear);
51
52 return () => {
53 clear();
54 removeTransitionStart();
55 removeFocus();
56 };
57 }, [navigation]);
58
59 return useCallback(
60 (thread: ThreadSelection) => {
61 navigation.dispatch((state) =>
62 createHomeThreadNavigationAction({
63 state,
64 dismissingRouteKey: dismissingRouteKey.current,
65 thread,
66 }),
67 );
68 },
69 [navigation],
70 );
71}
apps/mobile/src/features/home/thread-swipe-actions.tsx+31-20
......@@ -370,34 +370,45 @@ function SwipeActionButton(props: {
370370 readonly stretchesOnFullSwipe: boolean;
371371 readonly translation: SharedValue<number>;
372372}) {
373 const {
374 actionsWidth,
375 entryRange: [entryRangeStart, entryRangeEnd],
376 fullSwipeThreshold,
377 stretchesOnFullSwipe,
378 translation,
379 } = props;
373380 const circleSize = props.compact ? COMPACT_ACTION_CIRCLE_SIZE : ACTION_CIRCLE_SIZE;
374381 const iconSize = props.compact ? COMPACT_ACTION_ICON_SIZE : ACTION_ICON_SIZE;
375382 const actionStyle = useAnimatedStyle(() => {
376 const reveal = Math.max(-props.translation.value, 0);
377 const entryProgress = interpolate(reveal, props.entryRange, [0, 1], Extrapolation.CLAMP);
378 const stretch = Math.max(reveal - props.actionsWidth, 0);
383 const reveal = Math.max(-translation.value, 0);
384 const entryProgress = interpolate(
385 reveal,
386 [entryRangeStart, entryRangeEnd],
387 [0, 1],
388 Extrapolation.CLAMP,
389 );
390 const stretch = Math.max(reveal - actionsWidth, 0);
379391 const fullSwipeProgress = interpolate(
380392 reveal,
381 [props.actionsWidth, props.fullSwipeThreshold + 20],
393 [actionsWidth, fullSwipeThreshold + 20],
382394 [0, 1],
383395 Extrapolation.CLAMP,
384396 );
385397
386398 return {
387 opacity: props.stretchesOnFullSwipe ? entryProgress : entryProgress * (1 - fullSwipeProgress),
399 opacity: stretchesOnFullSwipe ? entryProgress : entryProgress * (1 - fullSwipeProgress),
388400 transform: [
389401 {
390402 translateX:
391 interpolate(entryProgress, [0, 1], [22, 0]) -
392 (props.stretchesOnFullSwipe ? 0 : stretch),
403 interpolate(entryProgress, [0, 1], [22, 0]) - (stretchesOnFullSwipe ? 0 : stretch),
393404 },
394405 { scale: interpolate(entryProgress, [0, 1], [0.78, 1]) },
395406 ],
396407 };
397408 });
398409 const circleStyle = useAnimatedStyle(() => {
399 const reveal = Math.max(-props.translation.value, 0);
400 const stretch = props.stretchesOnFullSwipe ? Math.max(reveal - props.actionsWidth, 0) : 0;
410 const reveal = Math.max(-translation.value, 0);
411 const stretch = stretchesOnFullSwipe ? Math.max(reveal - actionsWidth, 0) : 0;
401412
402413 return {
403414 transform: [{ translateX: -stretch }],
......@@ -405,11 +416,11 @@ function SwipeActionButton(props: {
405416 };
406417 });
407418 const iconStyle = useAnimatedStyle(() => {
408 const reveal = Math.max(-props.translation.value, 0);
409 const stretch = props.stretchesOnFullSwipe ? Math.max(reveal - props.actionsWidth, 0) : 0;
419 const reveal = Math.max(-translation.value, 0);
420 const stretch = stretchesOnFullSwipe ? Math.max(reveal - actionsWidth, 0) : 0;
410421 const armedProgress = interpolate(
411422 reveal,
412 [props.fullSwipeThreshold, props.fullSwipeThreshold + 20],
423 [fullSwipeThreshold, fullSwipeThreshold + 20],
413424 [0, 1],
414425 Extrapolation.CLAMP,
415426 );
......@@ -419,16 +430,16 @@ function SwipeActionButton(props: {
419430 };
420431 });
421432 const labelStyle = useAnimatedStyle(() => {
422 if (!props.stretchesOnFullSwipe) {
433 if (!stretchesOnFullSwipe) {
423434 return { opacity: 1 };
424435 }
425436
426 const reveal = Math.max(-props.translation.value, 0);
427 const stretch = Math.max(reveal - props.actionsWidth, 0);
437 const reveal = Math.max(-translation.value, 0);
438 const stretch = Math.max(reveal - actionsWidth, 0);
428439 return {
429440 opacity: interpolate(
430441 reveal,
431 [props.fullSwipeThreshold - 24, props.fullSwipeThreshold],
442 [fullSwipeThreshold - 24, fullSwipeThreshold],
432443 [1, 0],
433444 Extrapolation.CLAMP,
434445 ),
......@@ -532,17 +543,17 @@ export function ThreadSwipeActions(props: {
532543 readonly secondaryAction: ThreadSwipeSecondaryAction | null;
533544 readonly translation: SharedValue<number>;
534545}) {
535 const secondaryAction = props.secondaryAction;
546 const { fullSwipeThreshold, onFullSwipeArmedChange, secondaryAction, translation } = props;
536547 const fullSwipeIsPrimary = props.fullSwipeAction === "primary" || secondaryAction === null;
537548 const actionsWidth = swipeActionsWidth(secondaryAction !== null);
538549 useAnimatedReaction(
539 () => -props.translation.value >= props.fullSwipeThreshold,
550 () => -translation.value >= fullSwipeThreshold,
540551 (armed, previous) => {
541552 if (armed !== previous) {
542 runOnJS(props.onFullSwipeArmedChange)(armed);
553 runOnJS(onFullSwipeArmedChange)(armed);
543554 }
544555 },
545 [props.fullSwipeThreshold, props.onFullSwipeArmedChange],
556 [fullSwipeThreshold, onFullSwipeArmedChange, translation],
546557 );
547558
548559 return (
apps/mobile/src/lib/foundation-fast-refresh.ts created+21
......@@ -0,0 +1,21 @@
1export interface FoundationHotModule {
2 readonly accept: (callback?: () => void) => void;
3 readonly dispose: (callback: () => void) => void;
4}
5
6export function disposeOnFoundationReplace(
7 hotModule: FoundationHotModule | undefined,
8 dispose: () => void | Promise<void>,
9): void {
10 if (hotModule === undefined || typeof __DEV__ === "undefined" || !__DEV__) return;
11
12 hotModule.dispose(() => {
13 try {
14 void Promise.resolve(dispose()).catch((error: unknown) => {
15 console.error("[fast-refresh] could not dispose replaced mobile foundation", error);
16 });
17 } catch (error) {
18 console.error("[fast-refresh] could not dispose replaced mobile foundation", error);
19 }
20 });
21}
apps/mobile/src/lib/hot-swappable-atom-runtime.test.ts created+197
......@@ -0,0 +1,197 @@
1import { describe, expect, it, vi } from "vite-plus/test";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
6
7import { hotSwappableAtomRuntime } from "./hot-swappable-atom-runtime";
8
9class RuntimeValue extends Context.Service<RuntimeValue, { readonly value: string }>()(
10 "t3/mobile/test/RuntimeValue",
11) {}
12
13function runtimeLayer(value: string, events: string[]) {
14 return Layer.effect(
15 RuntimeValue,
16 Effect.acquireRelease(
17 Effect.sync(() => {
18 events.push(`acquire:${value}`);
19 return RuntimeValue.of({ value });
20 }),
21 () =>
22 Effect.sync(() => {
23 events.push(`release:${value}`);
24 }),
25 ),
26 );
27}
28
29function runtimeLayerWithRelease(
30 value: string,
31 events: string[],
32 release: Effect.Effect<void> = Effect.sync(() => {
33 events.push(`release:${value}`);
34 }),
35) {
36 return Layer.effect(
37 RuntimeValue,
38 Effect.acquireRelease(
39 Effect.sync(() => {
40 events.push(`acquire:${value}`);
41 return RuntimeValue.of({ value });
42 }),
43 () => release,
44 ),
45 );
46}
47
48describe("hotSwappableAtomRuntime", () => {
49 it("rebuilds a mounted runtime in place without disturbing unrelated subscribers", () => {
50 vi.stubGlobal("__DEV__", true);
51 const registry = AtomRegistry.make();
52 const accept = () => {};
53 const events: string[] = [];
54 const id = `test-${crypto.randomUUID()}`;
55 const runtime = hotSwappableAtomRuntime({
56 id,
57 hotModule: { accept },
58 registry,
59 layer: runtimeLayer("first", events),
60 });
61 const valueAtom = runtime.atom(RuntimeValue.pipe(Effect.map((service) => service.value)));
62 const values: string[] = [];
63 const unsubscribeRuntime = registry.subscribe(
64 valueAtom,
65 (result) => {
66 if (AsyncResult.isSuccess(result)) values.push(result.value);
67 },
68 { immediate: true },
69 );
70 const unrelatedAtom = Atom.make(0);
71 const unrelatedValues: number[] = [];
72 const unsubscribeUnrelated = registry.subscribe(unrelatedAtom, (value) => {
73 unrelatedValues.push(value);
74 });
75 registry.set(unrelatedAtom, 7);
76 const unwatchedDraftAtom = Atom.make("saved");
77 registry.set(unwatchedDraftAtom, "edited");
78 const nodesBefore = registry.getNodes().size;
79
80 const replacement = hotSwappableAtomRuntime({
81 id,
82 hotModule: { accept },
83 registry,
84 layer: runtimeLayer("second", events),
85 });
86 hotSwappableAtomRuntime({
87 id,
88 hotModule: { accept },
89 registry,
90 layer: runtimeLayer("third", events),
91 });
92 registry.set(unrelatedAtom, 8);
93
94 expect(replacement).toBe(runtime);
95 expect(events).toEqual([
96 "acquire:first",
97 "release:first",
98 "acquire:second",
99 "release:second",
100 "acquire:third",
101 ]);
102 expect(values).toEqual(["first", "second", "third"]);
103 expect(registry.get(unwatchedDraftAtom)).toBe("edited");
104 expect(unrelatedValues).toEqual([7, 8]);
105 expect(registry.getNodes().size).toBe(nodesBefore);
106 unsubscribeRuntime();
107 unsubscribeUnrelated();
108 registry.dispose();
109 expect(events).toEqual([
110 "acquire:first",
111 "release:first",
112 "acquire:second",
113 "release:second",
114 "acquire:third",
115 "release:third",
116 ]);
117 });
118
119 it("does not retain or accept a runtime outside development", () => {
120 vi.stubGlobal("__DEV__", false);
121 const registry = AtomRegistry.make();
122 const accept = vi.fn();
123 const layer = runtimeLayer("production", []);
124
125 const first = hotSwappableAtomRuntime({
126 id: "production",
127 hotModule: { accept },
128 registry,
129 layer,
130 });
131 const second = hotSwappableAtomRuntime({
132 id: "production",
133 hotModule: { accept },
134 registry,
135 layer,
136 });
137
138 expect(second).not.toBe(first);
139 expect(accept).not.toHaveBeenCalled();
140 registry.dispose();
141 });
142
143 it("starts an asynchronous old-layer release while exposing the fresh context", async () => {
144 vi.stubGlobal("__DEV__", true);
145 const registry = AtomRegistry.make();
146 const events: string[] = [];
147 const id = `test-${crypto.randomUUID()}`;
148 let finishRelease!: () => void;
149 const releaseGate = new Promise<void>((resolve) => {
150 finishRelease = resolve;
151 });
152 let markReleaseComplete!: () => void;
153 const releaseComplete = new Promise<void>((resolve) => {
154 markReleaseComplete = resolve;
155 });
156 const firstRelease = Effect.promise(async () => {
157 events.push("release:start:first");
158 await releaseGate;
159 events.push("release:end:first");
160 markReleaseComplete();
161 });
162 const runtime = hotSwappableAtomRuntime({
163 id,
164 hotModule: { accept() {} },
165 registry,
166 layer: runtimeLayerWithRelease("first", events, firstRelease),
167 });
168 const valueAtom = runtime.atom(RuntimeValue.pipe(Effect.map((service) => service.value)));
169 const values: string[] = [];
170 const unsubscribe = registry.subscribe(valueAtom, (result) => {
171 if (AsyncResult.isSuccess(result)) values.push(result.value);
172 });
173 registry.get(valueAtom);
174
175 hotSwappableAtomRuntime({
176 id,
177 hotModule: { accept() {} },
178 registry,
179 layer: runtimeLayer("second", events),
180 });
181
182 expect(events).toEqual(["acquire:first", "release:start:first", "acquire:second"]);
183 expect(values).toEqual(["first", "second"]);
184
185 finishRelease();
186 await releaseComplete;
187 expect(events).toEqual([
188 "acquire:first",
189 "release:start:first",
190 "acquire:second",
191 "release:end:first",
192 ]);
193
194 unsubscribe();
195 registry.dispose();
196 });
197});
apps/mobile/src/lib/hot-swappable-atom-runtime.ts created+63
......@@ -0,0 +1,63 @@
1import * as Layer from "effect/Layer";
2import { Atom, AtomRegistry, Reactivity } from "effect/unstable/reactivity";
3
4export interface AcceptingHotModule {
5 readonly accept: (callback?: () => void) => void;
6}
7
8interface HotAtomRuntimeEntry {
9 readonly layerAtom: Atom.Writable<
10 Layer.Layer<unknown, unknown, AtomRegistry.AtomRegistry | Reactivity.Reactivity>
11 >;
12 readonly runtime: Atom.AtomRuntime<unknown, unknown>;
13}
14
15const hotAtomRuntimesKey = Symbol.for("t3.mobile.hot-atom-runtimes");
16
17type HotAtomRuntimeGlobal = typeof globalThis & {
18 [hotAtomRuntimesKey]?: Map<string, HotAtomRuntimeEntry>;
19};
20
21function hotAtomRuntimes(): Map<string, HotAtomRuntimeEntry> {
22 const runtimeGlobal = globalThis as HotAtomRuntimeGlobal;
23 return (runtimeGlobal[hotAtomRuntimesKey] ??= new Map());
24}
25
26export function hotSwappableAtomRuntime<R, E>(options: {
27 readonly id: string;
28 readonly hotModule: AcceptingHotModule | undefined;
29 readonly registry: AtomRegistry.AtomRegistry;
30 readonly layer: Layer.Layer<R, E, AtomRegistry.AtomRegistry | Reactivity.Reactivity>;
31}): Atom.AtomRuntime<R, E> {
32 if (options.hotModule === undefined || typeof __DEV__ === "undefined" || !__DEV__) {
33 return Atom.runtime(options.layer);
34 }
35
36 const runtimes = hotAtomRuntimes();
37 const existing = runtimes.get(options.id);
38 let entry: HotAtomRuntimeEntry;
39
40 if (existing === undefined) {
41 const layerAtom = Atom.make(options.layer);
42 entry = {
43 layerAtom: layerAtom as HotAtomRuntimeEntry["layerAtom"],
44 runtime: Atom.runtime((get) => get(layerAtom)) as HotAtomRuntimeEntry["runtime"],
45 };
46 runtimes.set(options.id, entry);
47 } else {
48 entry = existing;
49 options.registry.set(
50 entry.layerAtom,
51 options.layer as Layer.Layer<
52 unknown,
53 unknown,
54 AtomRegistry.AtomRegistry | Reactivity.Reactivity
55 >,
56 );
57 }
58
59 // This is a real HMR boundary: importers retain the stable AtomRuntime while
60 // this module evaluation installs the freshly constructed Layer above.
61 options.hotModule.accept();
62 return entry.runtime as Atom.AtomRuntime<R, E>;
63}
apps/mobile/src/lib/runtime.ts+7
......@@ -9,6 +9,9 @@ import { managedRelayClientLayer } from "../features/cloud/managedRelayLayer";
99import { resolveCloudPublicConfig } from "../features/cloud/publicConfig";
1010import { tracingLayer } from "../features/observability/tracing";
1111import * as Persistence from "../persistence/layer";
12import { disposeOnFoundationReplace, type FoundationHotModule } from "./foundation-fast-refresh";
13
14declare const module: { readonly hot?: FoundationHotModule } | undefined;
1215
1316function configuredRelayUrl(): string {
1417 return resolveCloudPublicConfig().relay.url ?? "http://relay.invalid";
......@@ -43,3 +46,7 @@ export const runtimeContextLayer: Layer.Layer<
4346 Layer.Success<RuntimeLayerSource>,
4447 Layer.Error<RuntimeLayerSource>
4548> = Layer.effectContext(runtime.contextEffect);
49
50disposeOnFoundationReplace(typeof module === "undefined" ? undefined : module.hot, () =>
51 runtime.dispose(),
52);
apps/mobile/src/lib/uniwind-dev-refresh.test.ts created+97
......@@ -0,0 +1,97 @@
1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2import * as NodeURL from "node:url";
3
4vi.mock("react-native", () => ({
5 Appearance: {
6 addChangeListener: vi.fn(),
7 getColorScheme: () => "light",
8 setColorScheme: vi.fn(),
9 },
10 Platform: { constants: {}, OS: "ios" },
11}));
12
13vi.mock("../../node_modules/uniwind/src/core/listener", () => ({
14 UniwindListener: { notify() {}, notifyAll() {} },
15}));
16
17vi.mock("../../node_modules/uniwind/src/core/native", () => ({
18 UniwindStore: {
19 reinit: (generateStyleSheetCallback: () => unknown) => {
20 generateStyleSheetCallback();
21 },
22 runtime: { currentThemeName: "light", insets: {} },
23 vars: {},
24 },
25}));
26
27const loadUniwind = async () => {
28 const modulePath = NodeURL.fileURLToPath(
29 new URL("../../node_modules/uniwind/src/core/config/config.native.ts", import.meta.url),
30 );
31 const { Uniwind } = (await import(/* @vite-ignore */ modulePath)) as {
32 Uniwind: { readonly themes: Array<string> };
33 };
34 return Uniwind as typeof Uniwind & {
35 __reinit: (initialize: () => unknown, themes: Array<string>, fingerprint?: string) => void;
36 };
37};
38
39describe("Uniwind native stylesheet refresh", () => {
40 beforeEach(() => {
41 vi.resetModules();
42 vi.clearAllMocks();
43 vi.stubGlobal("__DEV__", true);
44 });
45
46 it("initializes once for identical generated styles", async () => {
47 const Uniwind = await loadUniwind();
48 const initialize = vi.fn(() => ({}));
49
50 Uniwind.__reinit(initialize, ["light", "dark"], "same-output");
51 Uniwind.__reinit(initialize, ["light", "dark"], "same-output");
52
53 expect(initialize).toHaveBeenCalledTimes(1);
54 });
55
56 it("reinitializes for changed generated styles and themes", async () => {
57 const Uniwind = await loadUniwind();
58 const initialize = vi.fn(() => ({}));
59
60 Uniwind.__reinit(initialize, ["light", "dark"], "before");
61 Uniwind.__reinit(initialize, ["light", "dark"], "after");
62 Uniwind.__reinit(initialize, ["light", "dark", "dim"], "themes-with-dim");
63
64 expect(initialize).toHaveBeenCalledTimes(3);
65 expect(Uniwind.themes).toEqual(["light", "dark", "dim"]);
66 });
67
68 it("retries the same output after initialization fails", async () => {
69 const Uniwind = await loadUniwind();
70 const initialize = vi
71 .fn()
72 .mockImplementationOnce(() => {
73 throw new Error("initialization failed");
74 })
75 .mockImplementationOnce(() => ({}));
76
77 expect(() => Uniwind.__reinit(initialize, ["light", "dark"], "retry-output")).toThrow(
78 "initialization failed",
79 );
80 Uniwind.__reinit(initialize, ["light", "dark"], "retry-output");
81
82 expect(initialize).toHaveBeenCalledTimes(2);
83 });
84
85 it("keeps no-fingerprint and production reinitialization semantics", async () => {
86 const Uniwind = await loadUniwind();
87 const initialize = vi.fn(() => ({}));
88
89 Uniwind.__reinit(initialize, ["light", "dark"]);
90 Uniwind.__reinit(initialize, ["light", "dark"]);
91 vi.stubGlobal("__DEV__", false);
92 Uniwind.__reinit(initialize, ["light", "dark"], "same-output");
93 Uniwind.__reinit(initialize, ["light", "dark"], "same-output");
94
95 expect(initialize).toHaveBeenCalledTimes(4);
96 });
97});
apps/mobile/src/state/atom-registry.ts+11
......@@ -1,3 +1,14 @@
11import { AtomRegistry } from "effect/unstable/reactivity";
22
3import {
4 disposeOnFoundationReplace,
5 type FoundationHotModule,
6} from "../lib/foundation-fast-refresh";
7
8declare const module: { readonly hot?: FoundationHotModule } | undefined;
9
310export const appAtomRegistry = AtomRegistry.make();
11
12disposeOnFoundationReplace(typeof module === "undefined" ? undefined : module.hot, () =>
13 appAtomRegistry.dispose(),
14);
apps/mobile/src/state/remote-environment-projections.test.ts created+161
......@@ -0,0 +1,161 @@
1import type {
2 EnvironmentPresentation,
3 PreparedConnection,
4} from "@t3tools/client-runtime/connection";
5import { PrimaryConnectionTarget } from "@t3tools/client-runtime/connection";
6import type { ServerConfig } from "@t3tools/contracts";
7import { EnvironmentId } from "@t3tools/contracts";
8import { describe, expect, it } from "@effect/vitest";
9import * as Option from "effect/Option";
10import { Atom, AtomRegistry } from "effect/unstable/reactivity";
11
12import { createRemoteEnvironmentProjectionAtoms } from "./remote-environment-projections";
13
14const ENVIRONMENT_ID = EnvironmentId.make("environment-1");
15const OTHER_ENVIRONMENT_ID = EnvironmentId.make("environment-2");
16
17function target(environmentId: EnvironmentId, endpoint: string = environmentId) {
18 return new PrimaryConnectionTarget({
19 environmentId,
20 label: `Environment ${environmentId}`,
21 httpBaseUrl: `https://${endpoint}.example.test`,
22 wsBaseUrl: `wss://${endpoint}.example.test`,
23 });
24}
25
26function presentation(
27 environmentId: EnvironmentId,
28 endpoint: string = environmentId,
29 serverConfig: ServerConfig | null = null,
30): EnvironmentPresentation {
31 return {
32 entry: { target: target(environmentId, endpoint), profile: Option.none() },
33 connection: { phase: "connected", error: null, traceId: null },
34 serverConfig,
35 };
36}
37
38function prepared(
39 environmentId: EnvironmentId,
40 endpoint: string,
41 token: string,
42): PreparedConnection {
43 return {
44 environmentId,
45 label: `Environment ${environmentId}`,
46 httpBaseUrl: `https://${endpoint}.example.test`,
47 socketUrl: `wss://${endpoint}.example.test/ws?token=redacted`,
48 httpAuthorization: { _tag: "Bearer", token },
49 target: target(environmentId, endpoint),
50 };
51}
52
53function makeHarness() {
54 const presentationAtoms = Atom.family((environmentId: EnvironmentId) =>
55 Atom.make<EnvironmentPresentation | null>(presentation(environmentId)),
56 );
57 const preparedConnectionAtoms = Atom.family((_environmentId: EnvironmentId) =>
58 Atom.make<Option.Option<PreparedConnection>>(Option.none()),
59 );
60 const serverConfigAtoms = Atom.family((_environmentId: EnvironmentId) =>
61 Atom.make<ServerConfig | null>(null),
62 );
63 const projections = createRemoteEnvironmentProjectionAtoms({
64 presentationAtom: presentationAtoms,
65 preparedConnectionAtom: preparedConnectionAtoms,
66 serverConfigAtom: serverConfigAtoms,
67 });
68
69 return {
70 registry: AtomRegistry.make(),
71 presentationAtom: presentationAtoms,
72 preparedConnectionAtom: preparedConnectionAtoms,
73 serverConfigAtom: serverConfigAtoms,
74 projections,
75 };
76}
77
78describe("remote environment projections", () => {
79 it("shares each environment projection and invalidates only changed inputs", () => {
80 const harness = makeHarness();
81 const firstConsumer = Atom.make((get) =>
82 get(harness.projections.savedConnectionAtom(ENVIRONMENT_ID)),
83 );
84 const secondConsumer = Atom.make((get) =>
85 get(harness.projections.savedConnectionAtom(ENVIRONMENT_ID)),
86 );
87 const otherConsumer = Atom.make((get) =>
88 get(harness.projections.savedConnectionAtom(OTHER_ENVIRONMENT_ID)),
89 );
90 const initial = harness.registry.get(firstConsumer);
91 const otherInitial = harness.registry.get(otherConsumer);
92
93 expect(harness.registry.get(secondConsumer)).toBe(initial);
94 expect(initial).toMatchObject({
95 environmentLabel: "Environment environment-1",
96 pairingUrl: "https://environment-1.example.test",
97 displayUrl: "https://environment-1.example.test",
98 httpBaseUrl: "https://environment-1.example.test",
99 wsBaseUrl: "wss://environment-1.example.test",
100 bearerToken: null,
101 });
102
103 harness.registry.set(
104 harness.preparedConnectionAtom(ENVIRONMENT_ID),
105 Option.some(prepared(ENVIRONMENT_ID, "rotated", "rotated-token")),
106 );
107 const rotated = harness.registry.get(firstConsumer);
108
109 expect(rotated).not.toBe(initial);
110 expect(rotated).toMatchObject({
111 httpBaseUrl: "https://rotated.example.test",
112 wsBaseUrl: "wss://rotated.example.test",
113 bearerToken: "rotated-token",
114 });
115 expect(harness.registry.get(secondConsumer)).toBe(rotated);
116 expect(harness.registry.get(otherConsumer)).toBe(otherInitial);
117
118 harness.registry.set(harness.preparedConnectionAtom(ENVIRONMENT_ID), Option.none());
119 harness.registry.set(
120 harness.presentationAtom(ENVIRONMENT_ID),
121 presentation(ENVIRONMENT_ID, "catalog-updated"),
122 );
123
124 expect(harness.registry.get(firstConsumer)).toMatchObject({
125 displayUrl: "https://catalog-updated.example.test",
126 httpBaseUrl: "https://catalog-updated.example.test",
127 wsBaseUrl: "wss://catalog-updated.example.test",
128 bearerToken: null,
129 });
130 });
131
132 it("preserves saved identity across config-only updates and refreshes runtime state", () => {
133 const harness = makeHarness();
134 const savedAtom = harness.projections.savedConnectionAtom(ENVIRONMENT_ID);
135 const runtimeAtom = harness.projections.runtimeStateAtom(ENVIRONMENT_ID);
136 const savedInitial = harness.registry.get(savedAtom);
137 const runtimeInitial = harness.registry.get(runtimeAtom);
138 const config = { cwd: "/repo" } as ServerConfig;
139 const initialPresentation = harness.registry.get(harness.presentationAtom(ENVIRONMENT_ID));
140
141 harness.registry.set(
142 harness.presentationAtom(ENVIRONMENT_ID),
143 initialPresentation === null ? null : { ...initialPresentation, serverConfig: config },
144 );
145 harness.registry.set(harness.serverConfigAtom(ENVIRONMENT_ID), config);
146
147 expect(harness.registry.get(savedAtom)).toBe(savedInitial);
148 expect(harness.registry.get(runtimeAtom)).not.toBe(runtimeInitial);
149 expect(harness.registry.get(runtimeAtom)?.serverConfig).toBe(config);
150 });
151
152 it("keeps missing environments null", () => {
153 const harness = makeHarness();
154 harness.registry.set(harness.presentationAtom(ENVIRONMENT_ID), null);
155
156 expect(
157 harness.registry.get(harness.projections.savedConnectionAtom(ENVIRONMENT_ID)),
158 ).toBeNull();
159 expect(harness.registry.get(harness.projections.runtimeStateAtom(ENVIRONMENT_ID))).toBeNull();
160 });
161});
apps/mobile/src/state/remote-environment-projections.ts created+120
......@@ -0,0 +1,120 @@
1import type {
2 EnvironmentPresentation,
3 PreparedConnection,
4} from "@t3tools/client-runtime/connection";
5import { connectionCatalogDisplayUrl } from "@t3tools/client-runtime/connection";
6import type { EnvironmentId, ServerConfig } from "@t3tools/contracts";
7import * as Option from "effect/Option";
8import { Atom } from "effect/unstable/reactivity";
9
10import type { SavedRemoteConnection } from "../lib/connection";
11import type { EnvironmentRuntimeState } from "./remote-runtime-types";
12
13export function createRemoteEnvironmentProjectionAtoms(input: {
14 readonly presentationAtom: (
15 environmentId: EnvironmentId,
16 ) => Atom.Atom<EnvironmentPresentation | null>;
17 readonly preparedConnectionAtom: (
18 environmentId: EnvironmentId,
19 ) => Atom.Atom<Option.Option<PreparedConnection>>;
20 readonly serverConfigAtom: (environmentId: EnvironmentId) => Atom.Atom<ServerConfig | null>;
21}) {
22 const savedConnectionAtom = Atom.family((environmentId: EnvironmentId) => {
23 let previousEntry: EnvironmentPresentation["entry"] | null = null;
24 let previousPrepared: PreparedConnection | null = null;
25 let previous: SavedRemoteConnection | null = null;
26
27 return Atom.make((get) => {
28 const presentation = get(input.presentationAtom(environmentId));
29 if (presentation === null) {
30 previousEntry = null;
31 previousPrepared = null;
32 previous = null;
33 return null;
34 }
35
36 const prepared = Option.getOrNull(get(input.preparedConnectionAtom(environmentId)));
37 if (
38 previous !== null &&
39 presentation.entry === previousEntry &&
40 prepared === previousPrepared
41 ) {
42 return previous;
43 }
44
45 const displayUrl = connectionCatalogDisplayUrl(presentation.entry) ?? "";
46 const httpBaseUrl = prepared?.httpBaseUrl ?? displayUrl;
47 const socketUrl = prepared?.socketUrl ?? "";
48 const wsBaseUrl =
49 socketUrl === ""
50 ? displayUrl.startsWith("https://")
51 ? displayUrl.replace(/^https:/, "wss:")
52 : displayUrl.replace(/^http:/, "ws:")
53 : new URL(socketUrl).origin;
54 const authorization = prepared?.httpAuthorization ?? null;
55 const relayManaged = presentation.entry.target._tag === "RelayConnectionTarget";
56
57 previousEntry = presentation.entry;
58 previousPrepared = prepared;
59 previous = {
60 environmentId,
61 environmentLabel: presentation.entry.target.label,
62 pairingUrl: displayUrl,
63 displayUrl,
64 httpBaseUrl,
65 wsBaseUrl,
66 bearerToken: authorization?._tag === "Bearer" ? authorization.token : null,
67 ...(relayManaged
68 ? {
69 authenticationMethod: "dpop" as const,
70 relayManaged: true as const,
71 ...(authorization?._tag === "Dpop"
72 ? { dpopAccessToken: authorization.accessToken }
73 : {}),
74 }
75 : { authenticationMethod: "bearer" as const }),
76 };
77 return previous;
78 }).pipe(Atom.withLabel(`mobile:saved-connection:${environmentId}`));
79 });
80
81 const runtimeStateAtom = Atom.family((environmentId: EnvironmentId) => {
82 let previousConnection: EnvironmentPresentation["connection"] | null = null;
83 let previousServerConfig: ServerConfig | null = null;
84 let previous: EnvironmentRuntimeState | null = null;
85
86 return Atom.make((get) => {
87 const presentation = get(input.presentationAtom(environmentId));
88 if (presentation === null) {
89 previousConnection = null;
90 previousServerConfig = null;
91 previous = null;
92 return null;
93 }
94
95 const connection = presentation.connection;
96 const serverConfig = get(input.serverConfigAtom(environmentId));
97 if (
98 previous !== null &&
99 connection.phase === previousConnection?.phase &&
100 connection.error === previousConnection?.error &&
101 connection.traceId === previousConnection?.traceId &&
102 serverConfig === previousServerConfig
103 ) {
104 return previous;
105 }
106
107 previousConnection = connection;
108 previousServerConfig = serverConfig;
109 previous = {
110 connectionState: connection.phase,
111 connectionError: connection.error,
112 connectionErrorTraceId: connection.traceId,
113 serverConfig,
114 };
115 return previous;
116 }).pipe(Atom.withLabel(`mobile:environment-runtime-state:${environmentId}`));
117 });
118
119 return { savedConnectionAtom, runtimeStateAtom };
120}
apps/mobile/src/state/use-remote-environment-registry.ts+30-73
......@@ -1,26 +1,20 @@
11import { useAtomValue } from "@effect/atom-react";
2import type { PreparedConnection } from "@t3tools/client-runtime/connection";
32import type { EnvironmentId } from "@t3tools/contracts";
4import type { ServerConfig } from "@t3tools/contracts";
53import * as Cause from "effect/Cause";
6import * as Option from "effect/Option";
74import { AsyncResult, Atom } from "effect/unstable/reactivity";
85import { useCallback, useMemo } from "react";
96import { Alert } from "react-native";
107
11import { useEnvironmentServerConfig } from "../state/entities";
128import { useConnectionController } from "../features/connection/useConnectionController";
13import { environmentPresentations, useEnvironmentPresentation } from "./presentation";
14import {
15 projectEnvironmentPresentation,
16 type EnvironmentPresentation,
17} from "../state/environments";
9import { environmentPresentations } from "./presentation";
1810import { useWorkspaceState } from "../state/workspace";
1911import type { SavedRemoteConnection } from "../lib/connection";
2012import { appAtomRegistry } from "./atom-registry";
2113import type { ConnectedEnvironmentSummary, EnvironmentRuntimeState } from "./remote-runtime-types";
22import { environmentSession, usePreparedConnection } from "./session";
14import { environmentSession } from "./session";
2315import { environmentCatalog } from "../connection/catalog";
16import { createRemoteEnvironmentProjectionAtoms } from "./remote-environment-projections";
17import { serverEnvironment } from "./server";
2418
2519const connectionPairingUrlAtom = Atom.make("").pipe(
2620 Atom.keepAlive,
......@@ -36,65 +30,30 @@ export function setPendingConnectionError(message: string | null): void {
3630 appAtomRegistry.set(pendingConnectionErrorAtom, message);
3731}
3832
39function toSavedConnection(
40 environment: EnvironmentPresentation,
41 prepared: Option.Option<PreparedConnection>,
42): SavedRemoteConnection {
43 const displayUrl = environment.displayUrl ?? "";
44 const active = Option.getOrNull(prepared);
45 const httpBaseUrl = active?.httpBaseUrl ?? displayUrl;
46 const socketUrl = active?.socketUrl ?? "";
47 const wsBaseUrl =
48 socketUrl === ""
49 ? displayUrl.startsWith("https://")
50 ? displayUrl.replace(/^https:/, "wss:")
51 : displayUrl.replace(/^http:/, "ws:")
52 : new URL(socketUrl).origin;
53 const authorization = active?.httpAuthorization ?? null;
33const remoteEnvironmentProjections = createRemoteEnvironmentProjectionAtoms({
34 presentationAtom: environmentPresentations.presentationAtom,
35 preparedConnectionAtom: environmentSession.preparedConnectionValueAtom,
36 serverConfigAtom: serverEnvironment.configValueAtom,
37});
5438
55 return {
56 environmentId: environment.environmentId,
57 environmentLabel: environment.label,
58 pairingUrl: displayUrl,
59 displayUrl,
60 httpBaseUrl,
61 wsBaseUrl,
62 bearerToken: authorization?._tag === "Bearer" ? authorization.token : null,
63 ...(environment.relayManaged
64 ? {
65 authenticationMethod: "dpop" as const,
66 relayManaged: true as const,
67 ...(authorization?._tag === "Dpop" ? { dpopAccessToken: authorization.accessToken } : {}),
68 }
69 : { authenticationMethod: "bearer" as const }),
70 };
71}
39const EMPTY_SAVED_CONNECTION_ATOM = Atom.make<SavedRemoteConnection | null>(null).pipe(
40 Atom.withLabel("mobile:saved-connection:empty"),
41);
42
43const EMPTY_RUNTIME_STATE_ATOM = Atom.make<EnvironmentRuntimeState | null>(null).pipe(
44 Atom.withLabel("mobile:environment-runtime-state:empty"),
45);
7246
7347const savedConnectionsByIdAtom = Atom.make((get) => {
7448 const presentationById = get(environmentPresentations.presentationsAtom);
7549 return Object.fromEntries(
76 [...presentationById.entries()].map(([environmentId, presentation]) => [
77 environmentId,
78 toSavedConnection(
79 projectEnvironmentPresentation(environmentId, presentation),
80 get(environmentSession.preparedConnectionValueAtom(environmentId)),
81 ),
82 ]),
50 [...presentationById.keys()].flatMap((environmentId) => {
51 const connection = get(remoteEnvironmentProjections.savedConnectionAtom(environmentId));
52 return connection === null ? [] : [[environmentId, connection]];
53 }),
8354 ) as Record<EnvironmentId, SavedRemoteConnection>;
8455}).pipe(Atom.withLabel("mobile:saved-connections-by-id"));
8556
86function toRuntimeState(
87 environment: EnvironmentPresentation,
88 serverConfig: ServerConfig | null,
89): EnvironmentRuntimeState {
90 return {
91 connectionState: environment.connection.phase,
92 connectionError: environment.connection.error,
93 connectionErrorTraceId: environment.connection.traceId,
94 serverConfig,
95 };
96}
97
9857export function useSavedRemoteConnections() {
9958 const catalog = useAtomValue(environmentCatalog.catalogValueAtom);
10059 const savedConnectionsById = useAtomValue(savedConnectionsByIdAtom);
......@@ -108,23 +67,21 @@ export function useSavedRemoteConnections() {
10867export function useSavedRemoteConnection(
10968 environmentId: EnvironmentId | null,
11069): SavedRemoteConnection | null {
111 const { presentation } = useEnvironmentPresentation(environmentId);
112 const prepared = usePreparedConnection(environmentId);
113 if (environmentId === null || presentation === null) {
114 return null;
115 }
116 return toSavedConnection(projectEnvironmentPresentation(environmentId, presentation), prepared);
70 return useAtomValue(
71 environmentId === null
72 ? EMPTY_SAVED_CONNECTION_ATOM
73 : remoteEnvironmentProjections.savedConnectionAtom(environmentId),
74 );
11775}
11876
11977export function useRemoteEnvironmentRuntime(
12078 environmentId: EnvironmentId | null,
12179): EnvironmentRuntimeState | null {
122 const { presentation } = useEnvironmentPresentation(environmentId);
123 const serverConfig = useEnvironmentServerConfig(environmentId);
124 if (environmentId === null || presentation === null) {
125 return null;
126 }
127 return toRuntimeState(projectEnvironmentPresentation(environmentId, presentation), serverConfig);
80 return useAtomValue(
81 environmentId === null
82 ? EMPTY_RUNTIME_STATE_ATOM
83 : remoteEnvironmentProjections.runtimeStateAtom(environmentId),
84 );
12885}
12986
13087export function useRemoteConnectionStatus() {
docs/internals/mobile-development.md created+50
......@@ -0,0 +1,50 @@
1# Mobile development lifecycle
2
3Ordinary component changes use Metro Fast Refresh. The connection runtime uses
4a stable development-only atom runtime whose writable layer atom receives the
5newly evaluated Effect layer. Its module accepts the update only after installing
6that layer. Existing subscribers observe the new context, and Effect releases the
7previous runtime scope without restarting React Navigation. Production uses an
8ordinary `Atom.runtime` without this hot-update boundary.
9
10Replacing the atom registry itself disposes its old nodes; replacing the managed
11runtime starts its asynchronous disposal. These modules use normal Metro update
12propagation. The app does not call `DevSettings.reload` during their replacement.
13React can still reset state when its normal Fast Refresh rules require it.
14
15Do not reset the shared registry to clean up a connection-runtime edit: registry
16reset removes listeners from unedited mounted consumers. Do not self-accept a
17runtime module while leaving importers attached to its old implementation. A
18hot-update boundary must install fresh behavior through the existing reactive
19dependencies. These boundaries do not make every module-level atom family in the
20app hot-swappable; new runtime singletons still need explicit ownership. Editing
21the registry or managed runtime can still rebuild a much larger dependency graph
22than an ordinary component or connection-runtime edit.
23
24Environment supervisor scopes are children of the connection registry scope.
25The registry's per-environment map supports targeted shutdown, but it cannot be
26the sole owner: a supervisor created after that map's finalizer has run must
27still inherit the closed parent scope. Otherwise interrupted startup or runtime
28replacement can leave a session and WebSocket alive outside the current registry.
29
30The compact Home list owns its minute-based presentation clock in a focus effect.
31Blur clears the interval without a render-driving focus subscription; focus
32refreshes the clock immediately. Other prop or state changes can still render
33the hidden list. Exact snooze-expiry timers remain active, and the visible iPad
34sidebar keeps its own minute updates.
35
36Connection and runtime projections are shared per environment. Thread selection
37consumers should read those atoms instead of repeatedly parsing the same socket
38URL or constructing new connection objects during each render.
39
40The Uniwind dependency patch still recompiles CSS on Metro updates so newly used
41classes are discovered. It fingerprints the generated native stylesheet and
42theme list, then skips development-only global invalidation when that output is
43unchanged. A changed stylesheet or theme list still resets the style caches and
44notifies subscribers. The digest is committed only after initialization succeeds.
45Web and production retain their existing initialization behavior.
46
47After installing or changing the Uniwind patch, restart Metro once with
48`vp run dev:client:reset` from `apps/mobile`. pnpm gives patched packages new
49filesystem paths, and cached transforms can otherwise retain references to the
50previous package. Ordinary development starts should retain the transform cache.
packages/client-runtime/src/connection/registry.test.ts+26
......@@ -4,13 +4,17 @@ import {
44 type OrchestrationShellSnapshot,
55} from "@t3tools/contracts";
66import { describe, expect, it } from "@effect/vitest";
7import * as Context from "effect/Context";
78import * as Deferred from "effect/Deferred";
89import * as Effect from "effect/Effect";
10import * as Exit from "effect/Exit";
911import * as Fiber from "effect/Fiber";
1012import * as Layer from "effect/Layer";
1113import * as Option from "effect/Option";
1214import * as Ref from "effect/Ref";
1315import * as Result from "effect/Result";
16import * as Scheduler from "effect/Scheduler";
17import * as Scope from "effect/Scope";
1418import * as Stream from "effect/Stream";
1519import * as SubscriptionRef from "effect/SubscriptionRef";
1620
......@@ -420,6 +424,28 @@ function awaitConnectionState(
420424}
421425
422426describe("EnvironmentRegistry", () => {
427 it.effect("does not acquire a session after the registry scope has already closed", () =>
428 Effect.gen(function* () {
429 const harness = yield* makeHarness([TARGET]);
430 const registryScope = yield* Scope.make();
431 const context = yield* Layer.build(harness.layer).pipe(Scope.provide(registryScope));
432 const registry = Context.get(context, EnvironmentRegistry.EnvironmentRegistry);
433 const dispatcher = new Scheduler.MixedScheduler("sync", () => () => {}).makeDispatcher();
434 const scheduler: Scheduler.Scheduler = {
435 executionMode: "sync",
436 shouldYield: () => false,
437 makeDispatcher: () => dispatcher,
438 };
439
440 yield* Scope.close(registryScope, Exit.void);
441 yield* registry.start.pipe(Effect.provideService(Scheduler.Scheduler, scheduler));
442 dispatcher.flush();
443
444 expect(yield* Ref.get(harness.sessions)).toHaveLength(0);
445 expect(yield* Ref.get(harness.releasedSessions)).toBe(0);
446 }),
447 );
448
423449 it.effect("hydrates connection profiles into catalog entries", () =>
424450 Effect.gen(function* () {
425451 const harness = yield* makeHarness([SSH_CONNECTION], [SSH_PROFILE]);
packages/client-runtime/src/connection/registry.ts+2-1
......@@ -126,6 +126,7 @@ interface EnvironmentServiceScope {
126126}
127127
128128export const make = Effect.gen(function* () {
129 const registryScope = yield* Scope.Scope;
129130 const storage = yield* Persistence.ConnectionTargetStore;
130131 const registrations = yield* Persistence.ConnectionRegistrationStore;
131132 const cache = yield* Persistence.EnvironmentCacheStore;
......@@ -249,7 +250,7 @@ export const make = Effect.gen(function* () {
249250 Effect.uninterruptible(
250251 Effect.gen(function* () {
251252 const environmentId = entry.target.environmentId;
252 const scope = yield* Scope.make();
253 const scope = yield* Scope.fork(registryScope);
253254 const supervisor = yield* EnvironmentSupervisor.make(entry, {
254255 initiallyDesired: false,
255256 }).pipe(
patches/@react-native-menu__menu@2.0.0.patch+257-15
......@@ -1,8 +1,94 @@
1diff --git a/ios/MenuViewManager.mm b/ios/MenuViewManager.mm
2index 3d149b3e7426b8f7c999603ddf1e15e12331c33a..e80fe2caae86e339fcb03408419a7d64b3e5b04f 100644
3--- a/ios/MenuViewManager.mm
4+++ b/ios/MenuViewManager.mm
5@@ -65,6 +65,10 @@ - (UIView *)view
6 * onOpenMenu: callback to be called when the menu is opened
7 */
8 RCT_EXPORT_VIEW_PROPERTY(onOpenMenu, RCTDirectEventBlock);
9+/**
10+ * onMenuInteractionStart: callback to be called when UIKit starts preparing a menu interaction
11+ */
12+RCT_EXPORT_VIEW_PROPERTY(onMenuInteractionStart, RCTDirectEventBlock);
13 /**
14 * shouldOpenOnLongPress: determines whether menu should be opened after long press or normal press
15 */
16diff --git a/ios/NewArch/FabricActionSheetView.swift b/ios/NewArch/FabricActionSheetView.swift
17index 484ff8a1d73a6d445273883c5b28b1c8e80901ec..bc92310d00369b853a80f4d2080578f3903191d2 100644
18--- a/ios/NewArch/FabricActionSheetView.swift
19+++ b/ios/NewArch/FabricActionSheetView.swift
20@@ -3,6 +3,7 @@ public class FabricActionSheetView: ActionSheetView, FabricViewImplementationPro
21 public var onPressAction: ((String) -> Void)?
22 public var onCloseMenu: (() -> Void)?
23 public var onOpenMenu: (() -> Void)?
24+ public var onMenuInteractionStart: (() -> Void)?
25
26 @objc override func sendButtonAction(_ action: String) {
27 if let onPress = onPressAction {
28diff --git a/ios/NewArch/FabricMenuViewImplementation.swift b/ios/NewArch/FabricMenuViewImplementation.swift
29index 33d27c5a66593ae1a9b143804413578eb79f1a7d..359cd19fa53d10c0374b2b537517ea8629e79d0c 100644
30--- a/ios/NewArch/FabricMenuViewImplementation.swift
31+++ b/ios/NewArch/FabricMenuViewImplementation.swift
32@@ -12,6 +12,7 @@ public class FabricMenuViewImplementation: MenuViewImplementation, FabricViewImp
33 public var onPressAction: ((String) -> Void)?
34 public var onCloseMenu: (() -> Void)?
35 public var onOpenMenu: (() -> Void)?
36+ public var onMenuInteractionStart: (() -> Void)?
37
38 @objc override func sendButtonAction(_ action: UIAction) {
39 if let onPress = onPressAction {
40@@ -31,4 +32,10 @@ public class FabricMenuViewImplementation: MenuViewImplementation, FabricViewImp
41 }
42 }
43
44+ @objc override func sendMenuInteractionStart() {
45+ if let onMenuInteractionStart = onMenuInteractionStart {
46+ onMenuInteractionStart()
47+ }
48+ }
49+
50 }
51diff --git a/ios/NewArch/FabricViewImplementationProtocol.swift b/ios/NewArch/FabricViewImplementationProtocol.swift
52index 2cef2fb8c74497da46775a490edc58cf8cbf29cc..54429c756eeecf6e96b55d62cc1e70213096f62a 100644
53--- a/ios/NewArch/FabricViewImplementationProtocol.swift
54+++ b/ios/NewArch/FabricViewImplementationProtocol.swift
55@@ -8,4 +8,5 @@ import Foundation
56 var onPressAction: ((String) -> Void)? { get set }
57 var onCloseMenu: (() -> Void)? { get set }
58 var onOpenMenu: (() -> Void)? { get set }
59+ var onMenuInteractionStart: (() -> Void)? { get set }
60 }
161diff --git a/ios/NewArch/MenuView.mm b/ios/NewArch/MenuView.mm
2index a54e619eb39e3402fa63f2ee4f734063d4f8fc58..066b01259a3c8ad9532688606be3d29a94a8f7d4 100644
62index a54e619eb39e3402fa63f2ee4f734063d4f8fc58..773f6f1f5d4e0663fd3211fe51085a7f3935e445 100644
363--- a/ios/NewArch/MenuView.mm
464+++ b/ios/NewArch/MenuView.mm
5@@ -105,6 +105,27 @@ - (void)onOpenMenu {
65@@ -46,6 +46,10 @@ - (instancetype)initWithFrame:(CGRect)frame
66 [self onOpenMenu];
67 };
68
69+ _view.onMenuInteractionStart = ^{
70+ [self onMenuInteractionStart];
71+ };
72+
73 _view.onCloseMenu = ^{
74 [self onCloseMenu];
75 };
76@@ -92,6 +96,14 @@ - (void)onOpenMenu {
77 }
78 }
79
80+- (void)onMenuInteractionStart {
81+ // If screen is already unmounted then there will be no event emitter
82+ const auto eventEmitter = [self getEventEmitter];
83+ if (eventEmitter != nullptr) {
84+ eventEmitter->onMenuInteractionStart({});
85+ }
86+}
87+
88 /**
89 Responsible for iterating through the C++ vector<struct> and convert each struct element to NSDictionary, then return it all in an NSArray
90 */
91@@ -105,6 +117,27 @@ - (void)onOpenMenu {
692 NSMutableArray<NSDictionary *> *subactionsArray = [NSMutableArray arrayWithCapacity:actions.size()];
793 if (action.subactions.size() > 0) {
894 for (const MenuViewActionsSubactionsStruct &subaction : action.subactions) {
......@@ -30,7 +116,7 @@ index a54e619eb39e3402fa63f2ee4f734063d4f8fc58..066b01259a3c8ad9532688606be3d29a
30116 NSDictionary *subactionDict = @{
31117 @"id": [NSString stringWithUTF8String:subaction.id.c_str()],
32118 @"title": [NSString stringWithUTF8String:subaction.title.c_str()],
33@@ -118,7 +139,9 @@ - (void)onOpenMenu {
119@@ -118,7 +151,9 @@ - (void)onOpenMenu {
34120 @"destructive": @(subaction.attributes.destructive),
35121 @"disabled": @(subaction.attributes.disabled),
36122 @"hidden": @(subaction.attributes.hidden),
......@@ -40,7 +126,7 @@ index a54e619eb39e3402fa63f2ee4f734063d4f8fc58..066b01259a3c8ad9532688606be3d29a
40126 };
41127 [subactionsArray addObject:subactionDict];
42128 }
43@@ -138,6 +161,7 @@ - (void)onOpenMenu {
129@@ -138,6 +173,7 @@ - (void)onOpenMenu {
44130 @"destructive": @(action.attributes.destructive),
45131 @"disabled": @(action.attributes.disabled),
46132 @"hidden": @(action.attributes.hidden),
......@@ -48,20 +134,50 @@ index a54e619eb39e3402fa63f2ee4f734063d4f8fc58..066b01259a3c8ad9532688606be3d29a
48134 },
49135 @"subactions": subactionsArray,
50136 };
137diff --git a/ios/OldArch/LegacyActionSheetView.swift b/ios/OldArch/LegacyActionSheetView.swift
138index 3cd1a2517624d99b51331c564fba9145d7c8d799..e458504bea6ef167d9b0db0c889f0f76ea965287 100644
139--- a/ios/OldArch/LegacyActionSheetView.swift
140+++ b/ios/OldArch/LegacyActionSheetView.swift
141@@ -3,6 +3,7 @@ public class LegacyActionSheetView: ActionSheetView {
142 @objc var onPressAction: RCTDirectEventBlock?
143 @objc var onCloseMenu: RCTDirectEventBlock?
144 @objc var onOpenMenu: RCTDirectEventBlock?
145+ @objc var onMenuInteractionStart: RCTDirectEventBlock?
146
147
148
149diff --git a/ios/OldArch/LegacyMenuViewImplementation.swift b/ios/OldArch/LegacyMenuViewImplementation.swift
150index 6a2a205f95c1001f3fdf6fb835815fa7c4c02c16..4f42b64349966fb1438f72439cec8256a76aab9c 100644
151--- a/ios/OldArch/LegacyMenuViewImplementation.swift
152+++ b/ios/OldArch/LegacyMenuViewImplementation.swift
153@@ -5,6 +5,7 @@ public class LegacyMenuViewImplementation: MenuViewImplementation {
154 @objc var onPressAction: RCTDirectEventBlock?
155 @objc var onCloseMenu: RCTDirectEventBlock?
156 @objc var onOpenMenu: RCTDirectEventBlock?
157+ @objc var onMenuInteractionStart: RCTDirectEventBlock?
158
159 @objc override func sendButtonAction(_ action: UIAction) {
160 if let onPress = onPressAction {
161@@ -24,4 +25,10 @@ public class LegacyMenuViewImplementation: MenuViewImplementation {
162 }
163 }
164
165+ @objc override func sendMenuInteractionStart() {
166+ if let onMenuInteractionStart = onMenuInteractionStart {
167+ onMenuInteractionStart([:])
168+ }
169+ }
170+
171 }
51172diff --git a/ios/Shared/MenuViewImplementation.swift b/ios/Shared/MenuViewImplementation.swift
52index 5c4e0da4292b15d3a27b5ea1555f11452a470815..db134864676ed83dbcd895d7a1bde38e8037a005 100644
173index 5c4e0da4292b15d3a27b5ea1555f11452a470815..4d8abe3c4fc45bda48c2958bc9ca933c77f2cc47 100644
53174--- a/ios/Shared/MenuViewImplementation.swift
54175+++ b/ios/Shared/MenuViewImplementation.swift
55@@ -59,18 +59,43 @@ public class MenuViewImplementation: UIButton {
56 self.setup()
176@@ -60,17 +60,46 @@ public class MenuViewImplementation: UIButton {
57177 }
58178
59+ // Presentation is tracked from the two delegate methods the class already
60+ // overrode. Overriding willDisplayMenuFor as well (even for bookkeeping)
61+ // shadows UIButton's own implementation and degrades the button-anchored
62+ // presentation into generic context-menu chrome — an empty header row with
63+ // a dismiss chevron appears above the actions.
64179 public override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
180- sendMenuOpen()
65181+ // Flush updates deferred by the presented-guard before the action
66182+ // provider snapshots self.menu (covers a stuck flag from an
67183+ // interaction that never ended cleanly).
......@@ -71,15 +187,24 @@ index 5c4e0da4292b15d3a27b5ea1555f11452a470815..db134864676ed83dbcd895d7a1bde38e
71187+ self.setup()
72188+ }
73189+ isMenuPresented = true
74 sendMenuOpen()
190+ sendMenuInteractionStart()
75191 return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in
76192 guard let self = self else { return nil }
77193 return self.menu
78194 }
79195 }
80196-
197+
198+ public override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willDisplayMenuFor configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) {
199+ // UIKit can request a configuration for a press that never opens a
200+ // menu. Notify React only once presentation actually begins, and
201+ // preserve UIButton's presentation behavior by calling super.
202+ super.contextMenuInteraction(interaction, willDisplayMenuFor: configuration, animator: animator)
203+ sendMenuOpen()
204+ }
81205+
82206 public override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willEndFor configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionAnimating?) {
207+ super.contextMenuInteraction(interaction, willEndFor: configuration, animator: animator)
83208 sendMenuClose()
84209+ isMenuPresented = false
85210+ if pendingMenu != nil {
......@@ -97,7 +222,7 @@ index 5c4e0da4292b15d3a27b5ea1555f11452a470815..db134864676ed83dbcd895d7a1bde38e
97222 func setup () {
98223 let menu = UIMenu(title: _title,
99224 identifier: nil,
100@@ -86,8 +111,98 @@ public class MenuViewImplementation: UIButton {
225@@ -86,8 +115,98 @@ public class MenuViewImplementation: UIButton {
101226 }
102227 }
103228
......@@ -196,6 +321,15 @@ index 5c4e0da4292b15d3a27b5ea1555f11452a470815..db134864676ed83dbcd895d7a1bde38e
196321 }
197322
198323 public override func reactSetFrame(_ frame: CGRect) {
324@@ -124,4 +243,8 @@ public class MenuViewImplementation: UIButton {
325 @objc func sendMenuOpen() {
326 // NO-OP (should be overriden by parent)
327 }
328+
329+ @objc func sendMenuInteractionStart() {
330+ // NO-OP (should be overriden by parent)
331+ }
332 }
199333diff --git a/ios/Shared/RCTMenuItem.swift b/ios/Shared/RCTMenuItem.swift
200334index bb6bb2b7ad56135089f267587c974b166760539d..949b3f7ec49af7ba26d966a8923a51f619443d5a 100644
201335--- a/ios/Shared/RCTMenuItem.swift
......@@ -220,8 +354,83 @@ index bb6bb2b7ad56135089f267587c974b166760539d..949b3f7ec49af7ba26d966a8923a51f6
220354 }
221355
222356 if #available(iOS 16.0, *) {
357diff --git a/lib/commonjs/NativeModuleSpecs/UIMenuNativeComponent.ts b/lib/commonjs/NativeModuleSpecs/UIMenuNativeComponent.ts
358index e6509355275596c451f9d082223cc16bfe504e1a..dd16e1a7403970cc3a404355f95ed99580776353 100644
359--- a/lib/commonjs/NativeModuleSpecs/UIMenuNativeComponent.ts
360+++ b/lib/commonjs/NativeModuleSpecs/UIMenuNativeComponent.ts
361@@ -48,6 +48,7 @@ export interface NativeProps extends ViewProps {
362 onPressAction?: DirectEventHandler<{ event: string }>;
363 onCloseMenu?: DirectEventHandler<{ event: string }>;
364 onOpenMenu?: DirectEventHandler<{ event: string }>;
365+ onMenuInteractionStart?: DirectEventHandler<{ event: string }>;
366 actions: Array<MenuAction>;
367 actionsHash: string; // just a workaround to make sure we don't have to manually compare MenuActions manually in C++ (since it's a struct and that's a pain)
368 title?: string;
369diff --git a/lib/module/NativeModuleSpecs/UIMenuNativeComponent.ts b/lib/module/NativeModuleSpecs/UIMenuNativeComponent.ts
370index e6509355275596c451f9d082223cc16bfe504e1a..dd16e1a7403970cc3a404355f95ed99580776353 100644
371--- a/lib/module/NativeModuleSpecs/UIMenuNativeComponent.ts
372+++ b/lib/module/NativeModuleSpecs/UIMenuNativeComponent.ts
373@@ -48,6 +48,7 @@ export interface NativeProps extends ViewProps {
374 onPressAction?: DirectEventHandler<{ event: string }>;
375 onCloseMenu?: DirectEventHandler<{ event: string }>;
376 onOpenMenu?: DirectEventHandler<{ event: string }>;
377+ onMenuInteractionStart?: DirectEventHandler<{ event: string }>;
378 actions: Array<MenuAction>;
379 actionsHash: string; // just a workaround to make sure we don't have to manually compare MenuActions manually in C++ (since it's a struct and that's a pain)
380 title?: string;
381diff --git a/lib/typescript/src/NativeModuleSpecs/UIMenuNativeComponent.d.ts b/lib/typescript/src/NativeModuleSpecs/UIMenuNativeComponent.d.ts
382index 24ee7fdcd9f81cfe2c813cfca9d4e276b846fb7b..5ee7b45bafea8547d68949aaba70584055f9fb53 100644
383--- a/lib/typescript/src/NativeModuleSpecs/UIMenuNativeComponent.d.ts
384+++ b/lib/typescript/src/NativeModuleSpecs/UIMenuNativeComponent.d.ts
385@@ -41,6 +41,9 @@ export interface NativeProps extends ViewProps {
386 onOpenMenu?: DirectEventHandler<{
387 event: string;
388 }>;
389+ onMenuInteractionStart?: DirectEventHandler<{
390+ event: string;
391+ }>;
392 actions: Array<MenuAction>;
393 actionsHash: string;
394 title?: string;
395diff --git a/lib/typescript/src/index.d.ts b/lib/typescript/src/index.d.ts
396index 61d210aa9364f0354f77bddaecdc0a127b1cdbb4..84ac2bc8b4edac045ed59896d5eb4e6eaef4725d 100644
397--- a/lib/typescript/src/index.d.ts
398+++ b/lib/typescript/src/index.d.ts
399@@ -4,6 +4,7 @@ declare const MenuView: import("react").ForwardRefExoticComponent<{
400 onPressAction?: ({ nativeEvent }: NativeActionEvent) => void;
401 onCloseMenu?: () => void;
402 onOpenMenu?: () => void;
403+ onMenuInteractionStart?: () => void;
404 actions: MenuAction[];
405 title?: string;
406 isAnchoredToRight?: boolean;
407diff --git a/lib/typescript/src/types.d.ts b/lib/typescript/src/types.d.ts
408index eac7940ab7eb6a69ac329848c9f3eeffdcbaf43d..271cd27f777be06bc02f05d4b4dac5bb940f5f0f 100644
409--- a/lib/typescript/src/types.d.ts
410+++ b/lib/typescript/src/types.d.ts
411@@ -113,6 +113,12 @@ type MenuComponentPropsBase = {
412 * Callback function that will be called when the menu opens.
413 */
414 onOpenMenu?: () => void;
415+ /**
416+ * Callback function that will be called when UIKit starts preparing a menu interaction.
417+ * Preparation can end without the menu opening.
418+ * @platform iOS
419+ */
420+ onMenuInteractionStart?: () => void;
421 /**
422 * Actions to be displayed in the menu.
423 */
424@@ -170,6 +176,7 @@ export type NativeMenuComponentProps = {
425 onPressAction?: ({ nativeEvent }: NativeActionEvent) => void;
426 onCloseMenu?: () => void;
427 onOpenMenu?: () => void;
428+ onMenuInteractionStart?: () => void;
429 actions: ProcessedMenuAction[];
430 actionsHash: string;
431 title?: string;
223432diff --git a/src/NativeModuleSpecs/UIMenuNativeComponent.ts b/src/NativeModuleSpecs/UIMenuNativeComponent.ts
224index e6509355275596c451f9d082223cc16bfe504e1a..c783cdaf73f5635cf9835824ca85fba3b46453b4 100644
433index e6509355275596c451f9d082223cc16bfe504e1a..604dba0d90a2545910f8724a80205565de2391d4 100644
225434--- a/src/NativeModuleSpecs/UIMenuNativeComponent.ts
226435+++ b/src/NativeModuleSpecs/UIMenuNativeComponent.ts
227436@@ -13,6 +13,22 @@ import codegenNativeComponent from "react-native/Libraries/Utilities/codegenNati
......@@ -267,3 +476,36 @@ index e6509355275596c451f9d082223cc16bfe504e1a..c783cdaf73f5635cf9835824ca85fba3
267476 };
268477 subactions?: Array<SubAction>;
269478 };
479@@ -48,6 +69,7 @@ export interface NativeProps extends ViewProps {
480 onPressAction?: DirectEventHandler<{ event: string }>;
481 onCloseMenu?: DirectEventHandler<{ event: string }>;
482 onOpenMenu?: DirectEventHandler<{ event: string }>;
483+ onMenuInteractionStart?: DirectEventHandler<{ event: string }>;
484 actions: Array<MenuAction>;
485 actionsHash: string; // just a workaround to make sure we don't have to manually compare MenuActions manually in C++ (since it's a struct and that's a pain)
486 title?: string;
487diff --git a/src/types.ts b/src/types.ts
488index 9c323d9c510e7359c83d4d6633affde673570632..5d5fdfb8b8235fcaa8295b85630443eaf5991a17 100644
489--- a/src/types.ts
490+++ b/src/types.ts
491@@ -123,6 +123,12 @@ type MenuComponentPropsBase = {
492 * Callback function that will be called when the menu opens.
493 */
494 onOpenMenu?: () => void;
495+ /**
496+ * Callback function that will be called when UIKit starts preparing a menu interaction.
497+ * Preparation can end without the menu opening.
498+ * @platform iOS
499+ */
500+ onMenuInteractionStart?: () => void;
501 /**
502 * Actions to be displayed in the menu.
503 */
504@@ -189,6 +195,7 @@ export type NativeMenuComponentProps = {
505 onPressAction?: ({ nativeEvent }: NativeActionEvent) => void;
506 onCloseMenu?: () => void;
507 onOpenMenu?: () => void;
508+ onMenuInteractionStart?: () => void;
509 actions: ProcessedMenuAction[];
510 actionsHash: string;
511 title?: string;
patches/uniwind@1.11.0.patch created+117
......@@ -0,0 +1,117 @@
1diff --git a/dist/metro/transformer.cjs b/dist/metro/transformer.cjs
2index d57ca81..fd8693c 100644
3--- a/dist/metro/transformer.cjs
4+++ b/dist/metro/transformer.cjs
5@@ -7,6 +7,7 @@ const culori = require('culori');
6 const node = require('@tailwindcss/node');
7 const oxide = require('@tailwindcss/oxide');
8 const fs = require('fs');
9+const node_crypto = require('node:crypto');
10
11 function _interopDefaultCompat (e) { return e && typeof e === 'object' && 'default' in e ? e.default : e; }
12
13@@ -1753,10 +1754,11 @@ const transform = async (config$1, projectRoot, filePath, data, options) => {
14 await bundlerConfig.generateArtifacts(cssArtifactPath);
15 const virtualCode = await compileCSS(bundlerConfig);
16 const isWeb = bundlerConfig.platform === config.Platform.Web;
17+ const nativeStylesFingerprint = isWeb ? void 0 : node_crypto.createHash("sha256").update(virtualCode).update("\0").update(bundlerConfig.stringifiedThemes).digest("hex");
18 data = Buffer.from(
19 isWeb ? virtualCode : [
20 `const { Uniwind } = require('uniwind');`,
21- `Uniwind.__reinit(rt => ${virtualCode}, ${bundlerConfig.stringifiedThemes});`
22+ `Uniwind.__reinit(rt => ${virtualCode}, ${bundlerConfig.stringifiedThemes}, '${nativeStylesFingerprint}');`
23 ].join(""),
24 "utf-8"
25 );
26diff --git a/dist/metro/transformer.mjs b/dist/metro/transformer.mjs
27index d3e7475..bf63587 100644
28--- a/dist/metro/transformer.mjs
29+++ b/dist/metro/transformer.mjs
30@@ -5,6 +5,7 @@ import { converter, parse, formatHex, formatHex8 } from 'culori';
31 import { compile } from '@tailwindcss/node';
32 import { Scanner } from '@tailwindcss/oxide';
33 import fs from 'fs';
34+import { createHash } from 'node:crypto';
35
36 const toCamelCase = (str) => str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
37 const pipe = (data) => ((...fns) => fns.reduce((acc, fn) => fn(acc), data));
38@@ -1746,10 +1747,11 @@ const transform = async (config, projectRoot, filePath, data, options) => {
39 await bundlerConfig.generateArtifacts(cssArtifactPath);
40 const virtualCode = await compileCSS(bundlerConfig);
41 const isWeb = bundlerConfig.platform === Platform.Web;
42+ const nativeStylesFingerprint = isWeb ? void 0 : createHash("sha256").update(virtualCode).update("\0").update(bundlerConfig.stringifiedThemes).digest("hex");
43 data = Buffer.from(
44 isWeb ? virtualCode : [
45 `const { Uniwind } = require('uniwind');`,
46- `Uniwind.__reinit(rt => ${virtualCode}, ${bundlerConfig.stringifiedThemes});`
47+ `Uniwind.__reinit(rt => ${virtualCode}, ${bundlerConfig.stringifiedThemes}, '${nativeStylesFingerprint}');`
48 ].join(""),
49 "utf-8"
50 );
51diff --git a/src/bundler/adapters/metro/transformer.ts b/src/bundler/adapters/metro/transformer.ts
52index 08059b0..05c0515 100644
53--- a/src/bundler/adapters/metro/transformer.ts
54+++ b/src/bundler/adapters/metro/transformer.ts
55@@ -5,6 +5,7 @@ import { Platform } from '@/common/consts'
56 import type * as ExpoMetroConfig from '@expo/metro-config'
57 import type * as MetroTransformWorker from 'metro-transform-worker'
58 import type { JsTransformerConfig, JsTransformOptions } from 'metro-transform-worker'
59+import { createHash } from 'node:crypto'
60 import path from 'path'
61
62 const cssArtifactPath = path.resolve(__dirname, '../../uniwind.css')
63@@ -69,13 +70,20 @@ export const transform = async (
64 await bundlerConfig.generateArtifacts(cssArtifactPath)
65 const virtualCode = await compileCSS(bundlerConfig)
66 const isWeb = bundlerConfig.platform === Platform.Web
67+ const nativeStylesFingerprint = isWeb
68+ ? undefined
69+ : createHash('sha256')
70+ .update(virtualCode)
71+ .update('\0')
72+ .update(bundlerConfig.stringifiedThemes)
73+ .digest('hex')
74
75 data = Buffer.from(
76 isWeb
77 ? virtualCode
78 : [
79 `const { Uniwind } = require('uniwind');`,
80- `Uniwind.__reinit(rt => ${virtualCode}, ${bundlerConfig.stringifiedThemes});`,
81+ `Uniwind.__reinit(rt => ${virtualCode}, ${bundlerConfig.stringifiedThemes}, '${nativeStylesFingerprint}');`,
82 ].join(''),
83 'utf-8',
84 )
85diff --git a/src/core/config/config.native.ts b/src/core/config/config.native.ts
86index 5e0832b..01aed1b 100644
87--- a/src/core/config/config.native.ts
88+++ b/src/core/config/config.native.ts
89@@ -8,6 +8,8 @@ import type { CSSVariables, GenerateStyleSheetsCallback, ThemeName } from '../ty
90 import { UniwindConfigBuilder as UniwindConfigBuilderBase } from './config.common'
91
92 class UniwindConfigBuilder extends UniwindConfigBuilderBase {
93+ private stylesFingerprint: string | undefined
94+
95 constructor() {
96 super()
97 }
98@@ -35,9 +37,18 @@ class UniwindConfigBuilder extends UniwindConfigBuilderBase {
99 UniwindListener.notify([StyleDependency.Insets])
100 }
101
102- protected __reinit(generateStyleSheetCallback: GenerateStyleSheetsCallback, themes: Array<string>) {
103+ protected __reinit(
104+ generateStyleSheetCallback: GenerateStyleSheetsCallback,
105+ themes: Array<string>,
106+ stylesFingerprint?: string,
107+ ) {
108+ if (__DEV__ && stylesFingerprint !== undefined && stylesFingerprint === this.stylesFingerprint) {
109+ return
110+ }
111+
112 super.__reinit(generateStyleSheetCallback, themes)
113 UniwindStore.reinit(generateStyleSheetCallback, themes)
114+ this.stylesFingerprint = stylesFingerprint
115 }
116
117 protected onThemeChange() {
pnpm-lock.yaml+124-222
......@@ -88,13 +88,14 @@ patchedDependencies:
8888 '@ff-labs/fff-node@0.9.4': ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368
8989 '@legendapp/list@3.3.5': 064530db83875fa671559a81ae42dc159726dc2dd6ec4c982da44ff7c7a74706
9090 '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa
91 '@react-native-menu/menu@2.0.0': c7f66d121c726ade4f5c4e1aed11a691e5711d244c544084e289ac26132a0045
91 '@react-native-menu/menu@2.0.0': f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd
9292 '@react-navigation/native-stack@7.17.6': e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552
9393 effect@4.0.0-beta.103: af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6
9494 react-native-gesture-handler@2.32.0: 808eb26f9e57cf4945efd3985af4d9c764da6f91f4c9764433cc868602bbf4d3
9595 react-native-keyboard-controller@1.21.13: 20be72c84d74253acdcfefbc6defe36dc396944f1a44cab2bdd0e3cd572ae008
9696 react-native-nitro-modules@0.35.9: 825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675
9797 react-native-screens@4.26.2: 149bef30a66351ea9b26b42f87b78c539cb52b880f2387bb323ec80dcac84006
98 uniwind@1.11.0: 329a77525509623d763b738152dbd00ab392cdcdd9fb6ed2b4a20e086e437196
9899
99100importers:
100101
......@@ -239,7 +240,7 @@ importers:
239240 version: 1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
240241 '@react-native-menu/menu':
241242 specifier: ^2.0.0
242 version: 2.0.0(patch_hash=c7f66d121c726ade4f5c4e1aed11a691e5711d244c544084e289ac26132a0045)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
243 version: 2.0.0(patch_hash=f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)
243244 '@react-navigation/elements':
244245 specifier: 2.9.26
245246 version: 2.9.26(c10301b6e0c42fc6434d2b643197a81e)
......@@ -436,8 +437,8 @@ importers:
436437 specifier: ^3.5.0
437438 version: 3.6.0
438439 uniwind:
439 specifier: ^1.6.2
440 version: 1.7.0(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0)
440 specifier: 1.11.0
441 version: 1.11.0(patch_hash=329a77525509623d763b738152dbd00ab392cdcdd9fb6ed2b4a20e086e437196)(@expo/metro-config@57.0.12(patch_hash=96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2)(bufferutil@4.1.0)(expo@57.0.18)(typescript@6.0.3)(utf-8-validate@6.0.6))(metro-cache@0.84.5)(metro-transform-worker@0.84.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(metro@0.84.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0)
441442 devDependencies:
442443 '@effect/vitest':
443444 specifier: 4.0.0-beta.103
......@@ -4412,17 +4413,11 @@ packages:
44124413 '@tabler/icons@3.44.0':
44134414 resolution: {integrity: sha512-Wn0AOZG9sg0L+bjfMqq4eNhC6pQjIrk94LvvWYNYkY8KH8wC3YILRzQlrnVJc4FUeMxH/AK97QsYCX35H3LndA==}
44144415
4415 '@tailwindcss/node@4.2.1':
4416 resolution: {integrity: sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==}
4417
44184416 '@tailwindcss/node@4.3.0':
44194417 resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==}
44204418
4421 '@tailwindcss/oxide-android-arm64@4.2.1':
4422 resolution: {integrity: sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==}
4423 engines: {node: '>= 20'}
4424 cpu: [arm64]
4425 os: [android]
4419 '@tailwindcss/node@4.3.2':
4420 resolution: {integrity: sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==}
44264421
44274422 '@tailwindcss/oxide-android-arm64@4.3.0':
44284423 resolution: {integrity: sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==}
......@@ -4430,11 +4425,11 @@ packages:
44304425 cpu: [arm64]
44314426 os: [android]
44324427
4433 '@tailwindcss/oxide-darwin-arm64@4.2.1':
4434 resolution: {integrity: sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==}
4428 '@tailwindcss/oxide-android-arm64@4.3.2':
4429 resolution: {integrity: sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==}
44354430 engines: {node: '>= 20'}
44364431 cpu: [arm64]
4437 os: [darwin]
4432 os: [android]
44384433
44394434 '@tailwindcss/oxide-darwin-arm64@4.3.0':
44404435 resolution: {integrity: sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==}
......@@ -4442,10 +4437,10 @@ packages:
44424437 cpu: [arm64]
44434438 os: [darwin]
44444439
4445 '@tailwindcss/oxide-darwin-x64@4.2.1':
4446 resolution: {integrity: sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==}
4440 '@tailwindcss/oxide-darwin-arm64@4.3.2':
4441 resolution: {integrity: sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==}
44474442 engines: {node: '>= 20'}
4448 cpu: [x64]
4443 cpu: [arm64]
44494444 os: [darwin]
44504445
44514446 '@tailwindcss/oxide-darwin-x64@4.3.0':
......@@ -4454,11 +4449,11 @@ packages:
44544449 cpu: [x64]
44554450 os: [darwin]
44564451
4457 '@tailwindcss/oxide-freebsd-x64@4.2.1':
4458 resolution: {integrity: sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==}
4452 '@tailwindcss/oxide-darwin-x64@4.3.2':
4453 resolution: {integrity: sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==}
44594454 engines: {node: '>= 20'}
44604455 cpu: [x64]
4461 os: [freebsd]
4456 os: [darwin]
44624457
44634458 '@tailwindcss/oxide-freebsd-x64@4.3.0':
44644459 resolution: {integrity: sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==}
......@@ -4466,11 +4461,11 @@ packages:
44664461 cpu: [x64]
44674462 os: [freebsd]
44684463
4469 '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1':
4470 resolution: {integrity: sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==}
4464 '@tailwindcss/oxide-freebsd-x64@4.3.2':
4465 resolution: {integrity: sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==}
44714466 engines: {node: '>= 20'}
4472 cpu: [arm]
4473 os: [linux]
4467 cpu: [x64]
4468 os: [freebsd]
44744469
44754470 '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0':
44764471 resolution: {integrity: sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==}
......@@ -4478,12 +4473,11 @@ packages:
44784473 cpu: [arm]
44794474 os: [linux]
44804475
4481 '@tailwindcss/oxide-linux-arm64-gnu@4.2.1':
4482 resolution: {integrity: sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==}
4476 '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2':
4477 resolution: {integrity: sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==}
44834478 engines: {node: '>= 20'}
4484 cpu: [arm64]
4479 cpu: [arm]
44854480 os: [linux]
4486 libc: [glibc]
44874481
44884482 '@tailwindcss/oxide-linux-arm64-gnu@4.3.0':
44894483 resolution: {integrity: sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==}
......@@ -4492,12 +4486,12 @@ packages:
44924486 os: [linux]
44934487 libc: [glibc]
44944488
4495 '@tailwindcss/oxide-linux-arm64-musl@4.2.1':
4496 resolution: {integrity: sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==}
4489 '@tailwindcss/oxide-linux-arm64-gnu@4.3.2':
4490 resolution: {integrity: sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==}
44974491 engines: {node: '>= 20'}
44984492 cpu: [arm64]
44994493 os: [linux]
4500 libc: [musl]
4494 libc: [glibc]
45014495
45024496 '@tailwindcss/oxide-linux-arm64-musl@4.3.0':
45034497 resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==}
......@@ -4506,12 +4500,12 @@ packages:
45064500 os: [linux]
45074501 libc: [musl]
45084502
4509 '@tailwindcss/oxide-linux-x64-gnu@4.2.1':
4510 resolution: {integrity: sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==}
4503 '@tailwindcss/oxide-linux-arm64-musl@4.3.2':
4504 resolution: {integrity: sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==}
45114505 engines: {node: '>= 20'}
4512 cpu: [x64]
4506 cpu: [arm64]
45134507 os: [linux]
4514 libc: [glibc]
4508 libc: [musl]
45154509
45164510 '@tailwindcss/oxide-linux-x64-gnu@4.3.0':
45174511 resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==}
......@@ -4520,12 +4514,12 @@ packages:
45204514 os: [linux]
45214515 libc: [glibc]
45224516
4523 '@tailwindcss/oxide-linux-x64-musl@4.2.1':
4524 resolution: {integrity: sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==}
4517 '@tailwindcss/oxide-linux-x64-gnu@4.3.2':
4518 resolution: {integrity: sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==}
45254519 engines: {node: '>= 20'}
45264520 cpu: [x64]
45274521 os: [linux]
4528 libc: [musl]
4522 libc: [glibc]
45294523
45304524 '@tailwindcss/oxide-linux-x64-musl@4.3.0':
45314525 resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==}
......@@ -4534,8 +4528,15 @@ packages:
45344528 os: [linux]
45354529 libc: [musl]
45364530
4537 '@tailwindcss/oxide-wasm32-wasi@4.2.1':
4538 resolution: {integrity: sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==}
4531 '@tailwindcss/oxide-linux-x64-musl@4.3.2':
4532 resolution: {integrity: sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==}
4533 engines: {node: '>= 20'}
4534 cpu: [x64]
4535 os: [linux]
4536 libc: [musl]
4537
4538 '@tailwindcss/oxide-wasm32-wasi@4.3.0':
4539 resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==}
45394540 engines: {node: '>=14.0.0'}
45404541 cpu: [wasm32]
45414542 bundledDependencies:
......@@ -4546,8 +4547,8 @@ packages:
45464547 - '@emnapi/wasi-threads'
45474548 - tslib
45484549
4549 '@tailwindcss/oxide-wasm32-wasi@4.3.0':
4550 resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==}
4550 '@tailwindcss/oxide-wasm32-wasi@4.3.2':
4551 resolution: {integrity: sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==}
45514552 engines: {node: '>=14.0.0'}
45524553 cpu: [wasm32]
45534554 bundledDependencies:
......@@ -4558,22 +4559,16 @@ packages:
45584559 - '@emnapi/wasi-threads'
45594560 - tslib
45604561
4561 '@tailwindcss/oxide-win32-arm64-msvc@4.2.1':
4562 resolution: {integrity: sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==}
4563 engines: {node: '>= 20'}
4564 cpu: [arm64]
4565 os: [win32]
4566
45674562 '@tailwindcss/oxide-win32-arm64-msvc@4.3.0':
45684563 resolution: {integrity: sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==}
45694564 engines: {node: '>= 20'}
45704565 cpu: [arm64]
45714566 os: [win32]
45724567
4573 '@tailwindcss/oxide-win32-x64-msvc@4.2.1':
4574 resolution: {integrity: sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==}
4568 '@tailwindcss/oxide-win32-arm64-msvc@4.3.2':
4569 resolution: {integrity: sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==}
45754570 engines: {node: '>= 20'}
4576 cpu: [x64]
4571 cpu: [arm64]
45774572 os: [win32]
45784573
45794574 '@tailwindcss/oxide-win32-x64-msvc@4.3.0':
......@@ -4582,14 +4577,20 @@ packages:
45824577 cpu: [x64]
45834578 os: [win32]
45844579
4585 '@tailwindcss/oxide@4.2.1':
4586 resolution: {integrity: sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==}
4580 '@tailwindcss/oxide-win32-x64-msvc@4.3.2':
4581 resolution: {integrity: sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==}
45874582 engines: {node: '>= 20'}
4583 cpu: [x64]
4584 os: [win32]
45884585
45894586 '@tailwindcss/oxide@4.3.0':
45904587 resolution: {integrity: sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==}
45914588 engines: {node: '>= 20'}
45924589
4590 '@tailwindcss/oxide@4.3.2':
4591 resolution: {integrity: sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==}
4592 engines: {node: '>= 20'}
4593
45934594 '@tailwindcss/vite@4.3.0':
45944595 resolution: {integrity: sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==}
45954596 peerDependencies:
......@@ -6309,6 +6310,10 @@ packages:
63096310 end-of-stream@1.4.5:
63106311 resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
63116312
6313 enhanced-resolve@5.21.6:
6314 resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==}
6315 engines: {node: '>=10.13.0'}
6316
63126317 enhanced-resolve@5.22.1:
63136318 resolution: {integrity: sha512-6QEuw3zoX1SJQc7b87aBXke/no+mG2bTBgw29gWMQonLmpEkWoCAVkl+M49e48AZlWzxiDzDZzYdp6kobcyLww==}
63146319 engines: {node: '>=10.13.0'}
......@@ -7480,12 +7485,6 @@ packages:
74807485 lighthouse-logger@1.4.2:
74817486 resolution: {integrity: sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==}
74827487
7483 lightningcss-android-arm64@1.31.1:
7484 resolution: {integrity: sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==}
7485 engines: {node: '>= 12.0.0'}
7486 cpu: [arm64]
7487 os: [android]
7488
74897488 lightningcss-android-arm64@1.32.0:
74907489 resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
74917490 engines: {node: '>= 12.0.0'}
......@@ -7498,12 +7497,6 @@ packages:
74987497 cpu: [arm64]
74997498 os: [darwin]
75007499
7501 lightningcss-darwin-arm64@1.31.1:
7502 resolution: {integrity: sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==}
7503 engines: {node: '>= 12.0.0'}
7504 cpu: [arm64]
7505 os: [darwin]
7506
75077500 lightningcss-darwin-arm64@1.32.0:
75087501 resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
75097502 engines: {node: '>= 12.0.0'}
......@@ -7516,12 +7509,6 @@ packages:
75167509 cpu: [x64]
75177510 os: [darwin]
75187511
7519 lightningcss-darwin-x64@1.31.1:
7520 resolution: {integrity: sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==}
7521 engines: {node: '>= 12.0.0'}
7522 cpu: [x64]
7523 os: [darwin]
7524
75257512 lightningcss-darwin-x64@1.32.0:
75267513 resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
75277514 engines: {node: '>= 12.0.0'}
......@@ -7534,12 +7521,6 @@ packages:
75347521 cpu: [x64]
75357522 os: [freebsd]
75367523
7537 lightningcss-freebsd-x64@1.31.1:
7538 resolution: {integrity: sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==}
7539 engines: {node: '>= 12.0.0'}
7540 cpu: [x64]
7541 os: [freebsd]
7542
75437524 lightningcss-freebsd-x64@1.32.0:
75447525 resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
75457526 engines: {node: '>= 12.0.0'}
......@@ -7552,12 +7533,6 @@ packages:
75527533 cpu: [arm]
75537534 os: [linux]
75547535
7555 lightningcss-linux-arm-gnueabihf@1.31.1:
7556 resolution: {integrity: sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==}
7557 engines: {node: '>= 12.0.0'}
7558 cpu: [arm]
7559 os: [linux]
7560
75617536 lightningcss-linux-arm-gnueabihf@1.32.0:
75627537 resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
75637538 engines: {node: '>= 12.0.0'}
......@@ -7571,13 +7546,6 @@ packages:
75717546 os: [linux]
75727547 libc: [glibc]
75737548
7574 lightningcss-linux-arm64-gnu@1.31.1:
7575 resolution: {integrity: sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==}
7576 engines: {node: '>= 12.0.0'}
7577 cpu: [arm64]
7578 os: [linux]
7579 libc: [glibc]
7580
75817549 lightningcss-linux-arm64-gnu@1.32.0:
75827550 resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
75837551 engines: {node: '>= 12.0.0'}
......@@ -7592,13 +7560,6 @@ packages:
75927560 os: [linux]
75937561 libc: [musl]
75947562
7595 lightningcss-linux-arm64-musl@1.31.1:
7596 resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
7597 engines: {node: '>= 12.0.0'}
7598 cpu: [arm64]
7599 os: [linux]
7600 libc: [musl]
7601
76027563 lightningcss-linux-arm64-musl@1.32.0:
76037564 resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
76047565 engines: {node: '>= 12.0.0'}
......@@ -7613,13 +7574,6 @@ packages:
76137574 os: [linux]
76147575 libc: [glibc]
76157576
7616 lightningcss-linux-x64-gnu@1.31.1:
7617 resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
7618 engines: {node: '>= 12.0.0'}
7619 cpu: [x64]
7620 os: [linux]
7621 libc: [glibc]
7622
76237577 lightningcss-linux-x64-gnu@1.32.0:
76247578 resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
76257579 engines: {node: '>= 12.0.0'}
......@@ -7634,13 +7588,6 @@ packages:
76347588 os: [linux]
76357589 libc: [musl]
76367590
7637 lightningcss-linux-x64-musl@1.31.1:
7638 resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
7639 engines: {node: '>= 12.0.0'}
7640 cpu: [x64]
7641 os: [linux]
7642 libc: [musl]
7643
76447591 lightningcss-linux-x64-musl@1.32.0:
76457592 resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
76467593 engines: {node: '>= 12.0.0'}
......@@ -7654,12 +7601,6 @@ packages:
76547601 cpu: [arm64]
76557602 os: [win32]
76567603
7657 lightningcss-win32-arm64-msvc@1.31.1:
7658 resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
7659 engines: {node: '>= 12.0.0'}
7660 cpu: [arm64]
7661 os: [win32]
7662
76637604 lightningcss-win32-arm64-msvc@1.32.0:
76647605 resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
76657606 engines: {node: '>= 12.0.0'}
......@@ -7672,12 +7613,6 @@ packages:
76727613 cpu: [x64]
76737614 os: [win32]
76747615
7675 lightningcss-win32-x64-msvc@1.31.1:
7676 resolution: {integrity: sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==}
7677 engines: {node: '>= 12.0.0'}
7678 cpu: [x64]
7679 os: [win32]
7680
76817616 lightningcss-win32-x64-msvc@1.32.0:
76827617 resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
76837618 engines: {node: '>= 12.0.0'}
......@@ -7688,10 +7623,6 @@ packages:
76887623 resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==}
76897624 engines: {node: '>= 12.0.0'}
76907625
7691 lightningcss@1.31.1:
7692 resolution: {integrity: sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==}
7693 engines: {node: '>= 12.0.0'}
7694
76957626 lightningcss@1.32.0:
76967627 resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
76977628 engines: {node: '>= 12.0.0'}
......@@ -9620,12 +9551,12 @@ packages:
96209551 tailwind-merge@3.6.0:
96219552 resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==}
96229553
9623 tailwindcss@4.2.1:
9624 resolution: {integrity: sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==}
9625
96269554 tailwindcss@4.3.0:
96279555 resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==}
96289556
9557 tailwindcss@4.3.2:
9558 resolution: {integrity: sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==}
9559
96299560 tapable@2.3.3:
96309561 resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==}
96319562 engines: {node: '>=6'}
......@@ -9881,12 +9812,22 @@ packages:
98819812 resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
98829813 engines: {node: '>= 10.0.0'}
98839814
9884 uniwind@1.7.0:
9885 resolution: {integrity: sha512-kIixWI3OprrWf/ypjglV2xubYvzCjtTKk110rQmXn8A3ZA/7z/nK6P/8EMxbfPCxjkdvhLcMniafa4qJcYIhZA==}
9815 uniwind@1.11.0:
9816 resolution: {integrity: sha512-e2mYlHrZzAQtZUf3STrfVDTetspv7p5vm/cu6p8kjLRQaL9r6QFG+MlwZ3hrI2LHnzgzjbSo+MwvoE380zz2JQ==}
9817 hasBin: true
98869818 peerDependencies:
9819 '@expo/metro-config': 57.0.12
9820 metro: '*'
9821 metro-cache: '*'
9822 metro-transform-worker: '*'
98879823 react: '>=19.0.0'
98889824 react-native: '>=0.81.0'
98899825 tailwindcss: '>=4'
9826 peerDependenciesMeta:
9827 '@expo/metro-config':
9828 optional: true
9829 metro-transform-worker:
9830 optional: true
98909831
98919832 unpipe@1.0.0:
98929833 resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
......@@ -13702,7 +13643,7 @@ snapshots:
1370213643 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
1370313644 optional: true
1370413645
13705 '@react-native-menu/menu@2.0.0(patch_hash=c7f66d121c726ade4f5c4e1aed11a691e5711d244c544084e289ac26132a0045)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)':
13646 '@react-native-menu/menu@2.0.0(patch_hash=f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)':
1370613647 dependencies:
1370713648 react: 19.2.3
1370813649 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
......@@ -14263,112 +14204,97 @@ snapshots:
1426314204
1426414205 '@tabler/icons@3.44.0': {}
1426514206
14266 '@tailwindcss/node@4.2.1':
14207 '@tailwindcss/node@4.3.0':
1426714208 dependencies:
1426814209 '@jridgewell/remapping': 2.3.5
1426914210 enhanced-resolve: 5.22.1
1427014211 jiti: 2.7.0
14271 lightningcss: 1.31.1
14212 lightningcss: 1.32.0
1427214213 magic-string: 0.30.21
1427314214 source-map-js: 1.2.1
14274 tailwindcss: 4.2.1
14215 tailwindcss: 4.3.0
1427514216
14276 '@tailwindcss/node@4.3.0':
14217 '@tailwindcss/node@4.3.2':
1427714218 dependencies:
1427814219 '@jridgewell/remapping': 2.3.5
14279 enhanced-resolve: 5.22.1
14220 enhanced-resolve: 5.21.6
1428014221 jiti: 2.7.0
1428114222 lightningcss: 1.32.0
1428214223 magic-string: 0.30.21
1428314224 source-map-js: 1.2.1
14284 tailwindcss: 4.3.0
14285
14286 '@tailwindcss/oxide-android-arm64@4.2.1':
14287 optional: true
14225 tailwindcss: 4.3.2
1428814226
1428914227 '@tailwindcss/oxide-android-arm64@4.3.0':
1429014228 optional: true
1429114229
14292 '@tailwindcss/oxide-darwin-arm64@4.2.1':
14230 '@tailwindcss/oxide-android-arm64@4.3.2':
1429314231 optional: true
1429414232
1429514233 '@tailwindcss/oxide-darwin-arm64@4.3.0':
1429614234 optional: true
1429714235
14298 '@tailwindcss/oxide-darwin-x64@4.2.1':
14236 '@tailwindcss/oxide-darwin-arm64@4.3.2':
1429914237 optional: true
1430014238
1430114239 '@tailwindcss/oxide-darwin-x64@4.3.0':
1430214240 optional: true
1430314241
14304 '@tailwindcss/oxide-freebsd-x64@4.2.1':
14242 '@tailwindcss/oxide-darwin-x64@4.3.2':
1430514243 optional: true
1430614244
1430714245 '@tailwindcss/oxide-freebsd-x64@4.3.0':
1430814246 optional: true
1430914247
14310 '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1':
14248 '@tailwindcss/oxide-freebsd-x64@4.3.2':
1431114249 optional: true
1431214250
1431314251 '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0':
1431414252 optional: true
1431514253
14316 '@tailwindcss/oxide-linux-arm64-gnu@4.2.1':
14254 '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2':
1431714255 optional: true
1431814256
1431914257 '@tailwindcss/oxide-linux-arm64-gnu@4.3.0':
1432014258 optional: true
1432114259
14322 '@tailwindcss/oxide-linux-arm64-musl@4.2.1':
14260 '@tailwindcss/oxide-linux-arm64-gnu@4.3.2':
1432314261 optional: true
1432414262
1432514263 '@tailwindcss/oxide-linux-arm64-musl@4.3.0':
1432614264 optional: true
1432714265
14328 '@tailwindcss/oxide-linux-x64-gnu@4.2.1':
14266 '@tailwindcss/oxide-linux-arm64-musl@4.3.2':
1432914267 optional: true
1433014268
1433114269 '@tailwindcss/oxide-linux-x64-gnu@4.3.0':
1433214270 optional: true
1433314271
14334 '@tailwindcss/oxide-linux-x64-musl@4.2.1':
14272 '@tailwindcss/oxide-linux-x64-gnu@4.3.2':
1433514273 optional: true
1433614274
1433714275 '@tailwindcss/oxide-linux-x64-musl@4.3.0':
1433814276 optional: true
1433914277
14340 '@tailwindcss/oxide-wasm32-wasi@4.2.1':
14278 '@tailwindcss/oxide-linux-x64-musl@4.3.2':
1434114279 optional: true
1434214280
1434314281 '@tailwindcss/oxide-wasm32-wasi@4.3.0':
1434414282 optional: true
1434514283
14346 '@tailwindcss/oxide-win32-arm64-msvc@4.2.1':
14284 '@tailwindcss/oxide-wasm32-wasi@4.3.2':
1434714285 optional: true
1434814286
1434914287 '@tailwindcss/oxide-win32-arm64-msvc@4.3.0':
1435014288 optional: true
1435114289
14352 '@tailwindcss/oxide-win32-x64-msvc@4.2.1':
14290 '@tailwindcss/oxide-win32-arm64-msvc@4.3.2':
1435314291 optional: true
1435414292
1435514293 '@tailwindcss/oxide-win32-x64-msvc@4.3.0':
1435614294 optional: true
1435714295
14358 '@tailwindcss/oxide@4.2.1':
14359 optionalDependencies:
14360 '@tailwindcss/oxide-android-arm64': 4.2.1
14361 '@tailwindcss/oxide-darwin-arm64': 4.2.1
14362 '@tailwindcss/oxide-darwin-x64': 4.2.1
14363 '@tailwindcss/oxide-freebsd-x64': 4.2.1
14364 '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.1
14365 '@tailwindcss/oxide-linux-arm64-gnu': 4.2.1
14366 '@tailwindcss/oxide-linux-arm64-musl': 4.2.1
14367 '@tailwindcss/oxide-linux-x64-gnu': 4.2.1
14368 '@tailwindcss/oxide-linux-x64-musl': 4.2.1
14369 '@tailwindcss/oxide-wasm32-wasi': 4.2.1
14370 '@tailwindcss/oxide-win32-arm64-msvc': 4.2.1
14371 '@tailwindcss/oxide-win32-x64-msvc': 4.2.1
14296 '@tailwindcss/oxide-win32-x64-msvc@4.3.2':
14297 optional: true
1437214298
1437314299 '@tailwindcss/oxide@4.3.0':
1437414300 optionalDependencies:
......@@ -14385,6 +14311,21 @@ snapshots:
1438514311 '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0
1438614312 '@tailwindcss/oxide-win32-x64-msvc': 4.3.0
1438714313
14314 '@tailwindcss/oxide@4.3.2':
14315 optionalDependencies:
14316 '@tailwindcss/oxide-android-arm64': 4.3.2
14317 '@tailwindcss/oxide-darwin-arm64': 4.3.2
14318 '@tailwindcss/oxide-darwin-x64': 4.3.2
14319 '@tailwindcss/oxide-freebsd-x64': 4.3.2
14320 '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.2
14321 '@tailwindcss/oxide-linux-arm64-gnu': 4.3.2
14322 '@tailwindcss/oxide-linux-arm64-musl': 4.3.2
14323 '@tailwindcss/oxide-linux-x64-gnu': 4.3.2
14324 '@tailwindcss/oxide-linux-x64-musl': 4.3.2
14325 '@tailwindcss/oxide-wasm32-wasi': 4.3.2
14326 '@tailwindcss/oxide-win32-arm64-msvc': 4.3.2
14327 '@tailwindcss/oxide-win32-x64-msvc': 4.3.2
14328
1438814329 '@tailwindcss/vite@4.3.0(@voidzero-dev/vite-plus-core@0.2.2(@types/node@24.12.4)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(yaml@2.9.0))':
1438914330 dependencies:
1439014331 '@tailwindcss/node': 4.3.0
......@@ -16174,6 +16115,11 @@ snapshots:
1617416115 dependencies:
1617516116 once: 1.4.0
1617616117
16118 enhanced-resolve@5.21.6:
16119 dependencies:
16120 graceful-fs: 4.2.11
16121 tapable: 2.3.3
16122
1617716123 enhanced-resolve@5.22.1:
1617816124 dependencies:
1617916125 graceful-fs: 4.2.11
......@@ -17676,99 +17622,66 @@ snapshots:
1767617622 transitivePeerDependencies:
1767717623 - supports-color
1767817624
17679 lightningcss-android-arm64@1.31.1:
17680 optional: true
17681
1768217625 lightningcss-android-arm64@1.32.0:
1768317626 optional: true
1768417627
1768517628 lightningcss-darwin-arm64@1.30.1:
1768617629 optional: true
1768717630
17688 lightningcss-darwin-arm64@1.31.1:
17689 optional: true
17690
1769117631 lightningcss-darwin-arm64@1.32.0:
1769217632 optional: true
1769317633
1769417634 lightningcss-darwin-x64@1.30.1:
1769517635 optional: true
1769617636
17697 lightningcss-darwin-x64@1.31.1:
17698 optional: true
17699
1770017637 lightningcss-darwin-x64@1.32.0:
1770117638 optional: true
1770217639
1770317640 lightningcss-freebsd-x64@1.30.1:
1770417641 optional: true
1770517642
17706 lightningcss-freebsd-x64@1.31.1:
17707 optional: true
17708
1770917643 lightningcss-freebsd-x64@1.32.0:
1771017644 optional: true
1771117645
1771217646 lightningcss-linux-arm-gnueabihf@1.30.1:
1771317647 optional: true
1771417648
17715 lightningcss-linux-arm-gnueabihf@1.31.1:
17716 optional: true
17717
1771817649 lightningcss-linux-arm-gnueabihf@1.32.0:
1771917650 optional: true
1772017651
1772117652 lightningcss-linux-arm64-gnu@1.30.1:
1772217653 optional: true
1772317654
17724 lightningcss-linux-arm64-gnu@1.31.1:
17725 optional: true
17726
1772717655 lightningcss-linux-arm64-gnu@1.32.0:
1772817656 optional: true
1772917657
1773017658 lightningcss-linux-arm64-musl@1.30.1:
1773117659 optional: true
1773217660
17733 lightningcss-linux-arm64-musl@1.31.1:
17734 optional: true
17735
1773617661 lightningcss-linux-arm64-musl@1.32.0:
1773717662 optional: true
1773817663
1773917664 lightningcss-linux-x64-gnu@1.30.1:
1774017665 optional: true
1774117666
17742 lightningcss-linux-x64-gnu@1.31.1:
17743 optional: true
17744
1774517667 lightningcss-linux-x64-gnu@1.32.0:
1774617668 optional: true
1774717669
1774817670 lightningcss-linux-x64-musl@1.30.1:
1774917671 optional: true
1775017672
17751 lightningcss-linux-x64-musl@1.31.1:
17752 optional: true
17753
1775417673 lightningcss-linux-x64-musl@1.32.0:
1775517674 optional: true
1775617675
1775717676 lightningcss-win32-arm64-msvc@1.30.1:
1775817677 optional: true
1775917678
17760 lightningcss-win32-arm64-msvc@1.31.1:
17761 optional: true
17762
1776317679 lightningcss-win32-arm64-msvc@1.32.0:
1776417680 optional: true
1776517681
1776617682 lightningcss-win32-x64-msvc@1.30.1:
1776717683 optional: true
1776817684
17769 lightningcss-win32-x64-msvc@1.31.1:
17770 optional: true
17771
1777217685 lightningcss-win32-x64-msvc@1.32.0:
1777317686 optional: true
1777417687
......@@ -17787,22 +17700,6 @@ snapshots:
1778717700 lightningcss-win32-arm64-msvc: 1.30.1
1778817701 lightningcss-win32-x64-msvc: 1.30.1
1778917702
17790 lightningcss@1.31.1:
17791 dependencies:
17792 detect-libc: 2.1.2
17793 optionalDependencies:
17794 lightningcss-android-arm64: 1.31.1
17795 lightningcss-darwin-arm64: 1.31.1
17796 lightningcss-darwin-x64: 1.31.1
17797 lightningcss-freebsd-x64: 1.31.1
17798 lightningcss-linux-arm-gnueabihf: 1.31.1
17799 lightningcss-linux-arm64-gnu: 1.31.1
17800 lightningcss-linux-arm64-musl: 1.31.1
17801 lightningcss-linux-x64-gnu: 1.31.1
17802 lightningcss-linux-x64-musl: 1.31.1
17803 lightningcss-win32-arm64-msvc: 1.31.1
17804 lightningcss-win32-x64-msvc: 1.31.1
17805
1780617703 lightningcss@1.32.0:
1780717704 dependencies:
1780817705 detect-libc: 2.1.2
......@@ -20463,10 +20360,10 @@ snapshots:
2046320360
2046420361 tailwind-merge@3.6.0: {}
2046520362
20466 tailwindcss@4.2.1: {}
20467
2046820363 tailwindcss@4.3.0: {}
2046920364
20365 tailwindcss@4.3.2: {}
20366
2047020367 tapable@2.3.3: {}
2047120368
2047220369 tar@7.5.16:
......@@ -20702,15 +20599,20 @@ snapshots:
2070220599
2070320600 universalify@2.0.1: {}
2070420601
20705 uniwind@1.7.0(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0):
20602 uniwind@1.11.0(patch_hash=329a77525509623d763b738152dbd00ab392cdcdd9fb6ed2b4a20e086e437196)(@expo/metro-config@57.0.12(patch_hash=96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2)(bufferutil@4.1.0)(expo@57.0.18)(typescript@6.0.3)(utf-8-validate@6.0.6))(metro-cache@0.84.5)(metro-transform-worker@0.84.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(metro@0.84.5(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(tailwindcss@4.3.0):
2070620603 dependencies:
20707 '@tailwindcss/node': 4.2.1
20708 '@tailwindcss/oxide': 4.2.1
20604 '@tailwindcss/node': 4.3.2
20605 '@tailwindcss/oxide': 4.3.2
2070920606 culori: 4.0.2
2071020607 lightningcss: 1.30.1
20608 metro: 0.84.5(bufferutil@4.1.0)(utf-8-validate@6.0.6)
20609 metro-cache: 0.84.5
2071120610 react: 19.2.3
2071220611 react-native: 0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)
2071320612 tailwindcss: 4.3.0
20613 optionalDependencies:
20614 '@expo/metro-config': 57.0.12(patch_hash=96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2)(bufferutil@4.1.0)(expo@57.0.18)(typescript@6.0.3)(utf-8-validate@6.0.6)
20615 metro-transform-worker: 0.84.5(bufferutil@4.1.0)(utf-8-validate@6.0.6)
2071420616
2071520617 unpipe@1.0.0: {}
2071620618
pnpm-workspace.yaml+1
......@@ -157,6 +157,7 @@ patchedDependencies:
157157 react-native-keyboard-controller@1.21.13: patches/react-native-keyboard-controller@1.21.13.patch
158158 react-native-nitro-modules@0.35.9: patches/react-native-nitro-modules@0.35.9.patch
159159 react-native-screens@4.26.2: patches/react-native-screens@4.26.2.patch
160 uniwind@1.11.0: patches/uniwind@1.11.0.patch
160161
161162peerDependencyRules:
162163 allowAny: