diff --git a/apps/mobile/src/features/agent-awareness/agentLiveActivity.ios.ts b/apps/mobile/src/features/agent-awareness/agentLiveActivity.ios.ts index 1cb7369af559..32141f220186 100644 --- a/apps/mobile/src/features/agent-awareness/agentLiveActivity.ios.ts +++ b/apps/mobile/src/features/agent-awareness/agentLiveActivity.ios.ts @@ -1,5 +1,9 @@ import AgentActivity, { type AgentActivityProps } from "../../widgets/AgentActivity"; +export function dismissEndedAgentLiveActivities() { + AgentActivity.dismissEnded(); +} + export function getAgentLiveActivities() { return AgentActivity.getInstances(); } diff --git a/apps/mobile/src/features/agent-awareness/agentLiveActivity.ts b/apps/mobile/src/features/agent-awareness/agentLiveActivity.ts index 18c3ffcf519b..684c57817eca 100644 --- a/apps/mobile/src/features/agent-awareness/agentLiveActivity.ts +++ b/apps/mobile/src/features/agent-awareness/agentLiveActivity.ts @@ -1,6 +1,8 @@ import type { LiveActivity } from "expo-widgets"; import type { AgentActivityProps } from "../../widgets/AgentActivity"; +export function dismissEndedAgentLiveActivities(): void {} + export function getAgentLiveActivities(): Array> { return []; } diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 9c06ec09e7ec..74c842be540f 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -58,6 +58,7 @@ vi.mock("./androidNotifications", () => ({ const secureStore = vi.hoisted(() => new Map()); const widgetMocks = vi.hoisted(() => ({ + dismissEnded: vi.fn(), getInstances: vi.fn(() => []), start: vi.fn(() => ({})), })); @@ -98,6 +99,7 @@ vi.mock("expo-widgets", () => ({ })); vi.mock("./agentLiveActivity", () => ({ + dismissEndedAgentLiveActivities: widgetMocks.dismissEnded, getAgentLiveActivities: widgetMocks.getInstances, startAgentLiveActivity: widgetMocks.start, })); @@ -271,6 +273,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { widgetMocks.getInstances.mockReset(); widgetMocks.getInstances.mockReturnValue([]); widgetMocks.start.mockClear(); + widgetMocks.dismissEnded.mockReset(); environmentConfigsMock.configs.clear(); }); @@ -621,6 +624,75 @@ describe("makeRelayDeviceRegistrationRequest", () => { }, ); + it.effect("dismisses ended Live Activities when the idle app returns to the foreground", () => { + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + + return Effect.gen(function* () { + yield* runBackgroundOperations(); + widgetMocks.dismissEnded.mockClear(); + + for (const listener of appStateMock.listeners) listener("background"); + yield* runBackgroundOperations(); + expect(widgetMocks.dismissEnded).not.toHaveBeenCalled(); + + for (const listener of appStateMock.listeners) listener("active"); + yield* runBackgroundOperations(); + + expect(widgetMocks.dismissEnded).toHaveBeenCalledOnce(); + expect(widgetMocks.start).not.toHaveBeenCalled(); + }).pipe(Effect.provide(layerRelayTest)); + }); + + it.effect("still registers the active card when ended-card cleanup throws", () => { + const registrations: unknown[] = []; + vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init); + if (request.url.endsWith("/v1/client/dpop-token")) { + return Response.json({ + access_token: "relay-dpop-token", + issued_token_type: "urn:ietf:params:oauth:token-type:access_token", + token_type: "DPoP", + expires_in: 300, + scope: "mobile:registration", + }); + } + if (request.url.endsWith("/v1/mobile/live-activities")) { + registrations.push(await request.json()); + } + return Response.json({ ok: true }); + }); + Constants.expoConfig!.extra = { relay: { url: "https://relay.example.test/" } }; + setAgentAwarenessRelayTokenProvider(() => Promise.resolve("clerk-token-user-a")); + const activity = { + getId: () => "activity-1", + getPushToken: vi.fn(() => Promise.resolve("activity-token")), + addPushTokenListener: vi.fn(), + end: vi.fn(), + }; + + return Effect.gen(function* () { + yield* runBackgroundOperations(); + widgetMocks.dismissEnded.mockReset(); + widgetMocks.dismissEnded.mockImplementation(() => { + throw new Error("Cannot find function dismissEnded"); + }); + widgetMocks.getInstances.mockReturnValue([activity] as never); + + for (const listener of appStateMock.listeners) listener("active"); + yield* runBackgroundOperations(); + + expect(widgetMocks.dismissEnded).toHaveBeenCalledOnce(); + expect(registrations).toEqual([ + { deviceId: "device-1", activityPushToken: "activity-token" }, + ]); + expect(activity.end).not.toHaveBeenCalled(); + expect(widgetMocks.start).not.toHaveBeenCalled(); + }).pipe( + Effect.provideService(FetchHttpClient.Fetch, globalThis.fetch), + Effect.provide(layerRelayTest), + ); + }); + it("ends local Live Activities and stops foreground reconciliation on cloud sign-out", () => { const end = vi.fn(() => Promise.resolve()); const activity = { @@ -636,6 +708,7 @@ describe("makeRelayDeviceRegistrationRequest", () => { setAgentAwarenessRelayTokenProvider(null); expect(end).toHaveBeenCalledWith("immediate"); + expect(widgetMocks.dismissEnded).toHaveBeenCalled(); expect(appStateMock.listeners).toHaveLength(0); }); diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index 4ab65816c7ff..ad2bc73fb63a 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -37,7 +37,11 @@ import { saveAgentAwarenessRegistrationRecord, } from "../../persistence/imperative"; import type { AgentActivityProps } from "../../widgets/AgentActivity"; -import { getAgentLiveActivities, startAgentLiveActivity } from "./agentLiveActivity"; +import { + dismissEndedAgentLiveActivities, + getAgentLiveActivities, + startAgentLiveActivity, +} from "./agentLiveActivity"; import { resolveCloudPublicConfig } from "../cloud/publicConfig"; import { supportsAgentAwarenessPush } from "./capabilities"; import { makeRelayDeviceRegistrationRequest, resolveApsEnvironment } from "./registrationPayload"; @@ -804,10 +808,20 @@ function ensureAppStateListener(): void { }); } +// Binaries built before the native dismissEnded method throw here. +function dismissEndedLocalLiveActivities(): void { + try { + dismissEndedAgentLiveActivities(); + } catch (error) { + logRegistrationError("ended live activity cleanup failed", error); + } +} + function endLocalLiveActivities(context: string): void { if (!canRegisterRemoteLiveActivities()) { return; } + dismissEndedLocalLiveActivities(); try { for (const activity of getAgentLiveActivities()) { activity.end("immediate").catch((error: unknown) => { @@ -1030,6 +1044,7 @@ export const refreshActiveLiveActivityRemoteRegistration = Effect.fn( return; } + dismissEndedLocalLiveActivities(); let activities = yield* Effect.try({ try: () => getAgentLiveActivities(), catch: (cause) => diff --git a/patches/expo-widgets@58.0.20.patch b/patches/expo-widgets@58.0.20.patch index 8b9db8b9d23a..21252dcd848a 100644 --- a/patches/expo-widgets@58.0.20.patch +++ b/patches/expo-widgets@58.0.20.patch @@ -1,5 +1,44 @@ +diff --git a/build/ExpoWidgets.js b/build/ExpoWidgets.js +index 85f3fbd..05b8dd8 100644 +--- a/build/ExpoWidgets.js ++++ b/build/ExpoWidgets.js +@@ -34,6 +34,7 @@ class LiveActivityFactoryStub { + getInstances() { + return []; + } ++ dismissEnded() {} + } + class CustomNotificationStub { + getId() { +diff --git a/build/Widgets.d.ts b/build/Widgets.d.ts +index fb37d5d..47a97af 100644 +--- a/build/Widgets.d.ts ++++ b/build/Widgets.d.ts +@@ -100,6 +100,8 @@ export declare class LiveActivityFactory { + * @platform ios + */ + getInstances(): LiveActivity[]; ++ /** Dismisses ended instances that iOS still shows on the Lock Screen. */ ++ dismissEnded(): void; + } + /** + * Creates a dismissal policy that removes the Live Activity at the specified time within a four-hour window. +diff --git a/build/Widgets.js b/build/Widgets.js +index cc1efce..3ce8897 100644 +--- a/build/Widgets.js ++++ b/build/Widgets.js +@@ -134,6 +134,9 @@ import ExpoWidgetsModule from './ExpoWidgets'; + */ getInstances() { + return this.nativeLiveActivityFactory.getInstances().map((instance)=>new LiveActivity(instance)); + } ++ dismissEnded() { ++ this.nativeLiveActivityFactory.dismissEnded(); ++ } + } + /** + * Creates a dismissal policy that removes the Live Activity at the specified time within a four-hour window. diff --git a/build/Widgets.types.d.ts b/build/Widgets.types.d.ts -index 4c474bb..b0173df 100644 +index 4c474bb..dbfb8b1 100644 --- a/build/Widgets.types.d.ts +++ b/build/Widgets.types.d.ts @@ -98,6 +98,10 @@ export type LiveActivityEnvironment = { @@ -13,21 +52,38 @@ index 4c474bb..b0173df 100644 /** * Whether the activity is displayed in a context with reduced luminance. * @platform iOS 16+ +@@ -307,6 +311,7 @@ export declare class NativeLiveActivityFactory extends SharedObject { + constructor(name: string, layout: string); + start(props?: string, url?: string, staleDate?: number): NativeLiveActivity; + getInstances(): NativeLiveActivity[]; ++ dismissEnded(): void; + } + export declare class NativeLiveActivity extends SharedObject { + getId(): string; diff --git a/ios/LiveActivityFactory.swift b/ios/LiveActivityFactory.swift -index 29e4f15..7143f9a 100644 +index 29e4f15..629e547 100644 --- a/ios/LiveActivityFactory.swift +++ b/ios/LiveActivityFactory.swift -@@ -19,6 +19,14 @@ final class LiveActivityFactory: SharedObject { - throw LiveActivitiesNotSupportedException() - } +@@ -14,7 +14,16 @@ final class LiveActivityFactory: SharedObject { + WidgetsStorage.set(layout, forKey: "__expo_widgets_live_activity_\(name)_layout") + } -+ // iOS ends an activity after 8 hours but leaves it frozen on the Lock Screen for up to -+ // 4 more. getInstances() can't see it, so a caller starting a replacement would leave -+ // both cards visible. Dismiss the ended ones now. ++ // iOS ends an activity after 8 hours but leaves it frozen on the Lock Screen for up to ++ // 4 more. getInstances() can't see it, so dismiss the ended ones directly. ++ func dismissEnded() { + for activity in Activity.activities + where activity.content.state.name == name && activity.activityState == .ended { + Task { await activity.end(nil, dismissalPolicy: .immediate) } + } ++ } ++ + func start(props: String?, url: URL?, staleDate: Date?) throws -> LiveActivity { + guard ActivityAuthorizationInfo().areActivitiesEnabled else { + throw LiveActivitiesNotSupportedException() + } +@@ -22,3 +31,6 @@ final class LiveActivityFactory: SharedObject { ++ // A replacement started beside an ended card would leave both visible. ++ dismissEnded() + do { let initialState = LiveActivityAttributes.ContentState(name: name, props: props) @@ -150,8 +206,45 @@ index 4d135c4..dae4744 100644 if #available(iOS 18.0, *) { LiveActivityBanner(context: context, nodes: nodes) } else if let node = nodes["banner"] as? [String: Any] { +diff --git a/ios/WidgetsModule.swift b/ios/WidgetsModule.swift +index 285136b..d36eac4 100644 +--- a/ios/WidgetsModule.swift ++++ b/ios/WidgetsModule.swift +@@ -97,4 +97,8 @@ public final class WidgetsModule: Module { + Function("getInstances") { (liveActivity: LiveActivityFactory) in + try liveActivity.getInstances() + } ++ ++ Function("dismissEnded") { (liveActivity: LiveActivityFactory) in ++ liveActivity.dismissEnded() ++ } + } +diff --git a/src/ExpoWidgets.ts b/src/ExpoWidgets.ts +index db15000..6863dc9 100644 +--- a/src/ExpoWidgets.ts ++++ b/src/ExpoWidgets.ts +@@ -59,4 +59,5 @@ class LiveActivityFactoryStub { + getInstances(): NativeLiveActivity[] { + return []; + } ++ dismissEnded(): void {} + } +diff --git a/src/Widgets.ts b/src/Widgets.ts +index 814b312..9818907 100644 +--- a/src/Widgets.ts ++++ b/src/Widgets.ts +@@ -213,4 +213,9 @@ export class LiveActivityFactory { + .getInstances() + .map((instance) => new LiveActivity(instance)); + } ++ ++ /** Dismisses ended instances that iOS still shows on the Lock Screen. */ ++ dismissEnded(): void { ++ this.nativeLiveActivityFactory.dismissEnded(); ++ } + } diff --git a/src/Widgets.types.ts b/src/Widgets.types.ts -index 690d069..ffab6f3 100644 +index 690d069..c60dd79 100644 --- a/src/Widgets.types.ts +++ b/src/Widgets.types.ts @@ -112,6 +112,10 @@ export type LiveActivityEnvironment = { @@ -165,3 +258,9 @@ index 690d069..ffab6f3 100644 /** * Whether the activity is displayed in a context with reduced luminance. * @platform iOS 16+ +@@ -337,4 +341,5 @@ export declare class NativeLiveActivityFactory extends SharedObject { + constructor(name: string, layout: string); + start(props?: string, url?: string, staleDate?: number): NativeLiveActivity; + getInstances(): NativeLiveActivity[]; ++ dismissEnded(): void; + } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c308ec14167d..c3558921ab6a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -183,7 +183,7 @@ patchedDependencies: expo-modules-core@58.0.22: f0b0fd100550ed76e5c25b4d13eb7c9ff62825f8f197c53edf7bf447c50c9fb7 expo-network@58.0.3: cd09b6d1744dde4efa081b5619ebcff38877d98f7502a51cab121629dece8237 expo-sharing@58.0.19: 61a9741dc6166fdbb5baa2e489275a65dd21aa11d2c915f490a1b337a4468e76 - expo-widgets@58.0.20: 8623cb034b720c5ef2da8012c765805a7298909bd92eaf8381f64dbde41b77dd + expo-widgets@58.0.20: 8bf49067f93742855e15f29559ac75c8b50230b47069060993c9ea01dac11171 mdast-util-math@3.0.0: afe6b2c89e1eaddfd3c90cf2723f12c9a17d513e65beff51653781486013251c micromark-extension-gfm-table@2.1.1: 9ad23a530e45d9f83cba1da8654fe16d5af8b59d5c73d5c7a7c91fd25b9be1d8 micromark-extension-llm-math@3.1.0: 49ab4815c18b003f6af998dcc6c258c17a21b50b2cc87745f12d4bd8b6d591f9 @@ -553,7 +553,7 @@ importers: version: 58.0.6(expo@58.0.14)(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6)) expo-widgets: specifier: 58.0.20 - version: 58.0.20(patch_hash=8623cb034b720c5ef2da8012c765805a7298909bd92eaf8381f64dbde41b77dd)(@babel/core@7.29.7)(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) + version: 58.0.20(patch_hash=8bf49067f93742855e15f29559ac75c8b50230b47069060993c9ea01dac11171)(@babel/core@7.29.7)(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) react: specifier: 'catalog:' version: 19.3.0 @@ -18460,7 +18460,7 @@ snapshots: optionalDependencies: '@babel/runtime': 7.29.7 expo: 58.0.14(1106b1d11fc0fad6d0556b6a93329d99) - expo-widgets: 58.0.20(patch_hash=8623cb034b720c5ef2da8012c765805a7298909bd92eaf8381f64dbde41b77dd)(@babel/core@7.29.7)(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) + expo-widgets: 58.0.20(patch_hash=8bf49067f93742855e15f29559ac75c8b50230b47069060993c9ea01dac11171)(@babel/core@7.29.7)(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0) transitivePeerDependencies: - '@babel/core' - supports-color @@ -19919,7 +19919,7 @@ snapshots: expo: 58.0.14(1106b1d11fc0fad6d0556b6a93329d99) react-native: 0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6) - expo-widgets@58.0.20(patch_hash=8623cb034b720c5ef2da8012c765805a7298909bd92eaf8381f64dbde41b77dd)(@babel/core@7.29.7)(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): + expo-widgets@58.0.20(patch_hash=8bf49067f93742855e15f29559ac75c8b50230b47069060993c9ea01dac11171)(@babel/core@7.29.7)(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0): dependencies: '@expo/plist': 0.10.1 '@expo/ui': 58.0.20(patch_hash=5a26480373850c1a7943919aa21f904151b335a0cb01042109e0746def37923e)(@babel/core@7.29.7)(expo@58.0.14)(react-dom@19.3.0(react@19.3.0))(react-native-worklets@0.13.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7)(@react-native/metro-config@0.88.0(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(utf-8-validate@6.0.6))(react@19.3.0)