diff --git a/apps/meteor/client/hooks/useReactiveValue.ts b/apps/meteor/client/hooks/useReactiveValue.ts deleted file mode 100644 index 6c66f227e4d0b..0000000000000 --- a/apps/meteor/client/hooks/useReactiveValue.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { useMemo, useSyncExternalStore } from 'react'; - -import { createReactiveSubscriptionFactory } from '../lib/createReactiveSubscriptionFactory'; - -export const useReactiveValue = (computeCurrentValue: () => T): T => { - const [subscribe, getSnapshot] = useMemo(() => createReactiveSubscriptionFactory(computeCurrentValue)(), [computeCurrentValue]); - - return useSyncExternalStore(subscribe, getSnapshot); -}; diff --git a/apps/meteor/client/providers/AuthenticationProvider/AuthenticationProvider.tsx b/apps/meteor/client/providers/AuthenticationProvider/AuthenticationProvider.tsx index d39b710b07cc6..ca655983ea509 100644 --- a/apps/meteor/client/providers/AuthenticationProvider/AuthenticationProvider.tsx +++ b/apps/meteor/client/providers/AuthenticationProvider/AuthenticationProvider.tsx @@ -4,11 +4,10 @@ import { AuthenticationContext, useSetting } from '@rocket.chat/ui-contexts'; import { Accounts } from 'meteor/accounts-base'; import { Meteor } from 'meteor/meteor'; import type { ContextType, ReactElement, ReactNode } from 'react'; -import { useMemo } from 'react'; +import { useMemo, useSyncExternalStore } from 'react'; import { useLDAPAndCrowdCollisionWarning } from './hooks/useLDAPAndCrowdCollisionWarning'; import { capitalize as capitalizeService } from '../../../lib/utils/stringUtils'; -import { useReactiveValue } from '../../hooks/useReactiveValue'; import { loginServices } from '../../lib/loginServices'; import { getDdpSdk } from '../../lib/sdk/ddpSdk'; import { STORAGE_KEYS, getStoredItem, removeStoredItem } from '../../lib/sdk/storage'; @@ -29,7 +28,34 @@ const callLoginMethod = ( }); }; -const getLoggingIn = () => Accounts.loggingIn(); +// Bridge Accounts.loggingIn() — Meteor's Tracker-reactive flag — into a +// non-reactive subscribe/getSnapshot pair for useSyncExternalStore. We hook +// `_setLoggingIn` (Meteor's internal flip, also accessed in +// apps/meteor/client/meteor/overrides/killMeteorStream.ts) to fan out +// transitions without entering a Tracker computation. +const loggingInListeners = new Set<() => void>(); +let loggingInBridgeInstalled = false; +const installLoggingInBridge = (): void => { + if (loggingInBridgeInstalled) return; + loggingInBridgeInstalled = true; + const wrap = Accounts as unknown as { _setLoggingIn?: (v: boolean) => void }; + const original = wrap._setLoggingIn; + if (typeof original !== 'function') return; + wrap._setLoggingIn = function (this: typeof Accounts, v: boolean) { + original.call(this, v); + loggingInListeners.forEach((cb) => cb()); + }; +}; + +const subscribeLoggingIn = (cb: () => void): (() => void) => { + installLoggingInBridge(); + loggingInListeners.add(cb); + return () => { + loggingInListeners.delete(cb); + }; +}; + +const getLoggingInSnapshot = (): boolean => Accounts.loggingIn(); const AuthenticationProvider = ({ children }: AuthenticationProviderProps): ReactElement => { const isLdapEnabled = useSetting('LDAP_Enable', false); @@ -39,7 +65,7 @@ const AuthenticationProvider = ({ children }: AuthenticationProviderProps): Reac useLDAPAndCrowdCollisionWarning(); - const isLoggingIn = useReactiveValue(getLoggingIn); + const isLoggingIn = useSyncExternalStore(subscribeLoggingIn, getLoggingInSnapshot); const contextValue = useMemo( (): ContextType => ({ diff --git a/apps/meteor/client/providers/ServerProvider.tsx b/apps/meteor/client/providers/ServerProvider.tsx index 7c568d9513e2a..17c7c19e71aa4 100644 --- a/apps/meteor/client/providers/ServerProvider.tsx +++ b/apps/meteor/client/providers/ServerProvider.tsx @@ -11,13 +11,11 @@ import type { Method, PathFor, OperationParams, OperationResult, UrlParams, Path import type { UploadResult, ServerContextValue } from '@rocket.chat/ui-contexts'; import { ServerContext } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; -import { Tracker } from 'meteor/tracker'; import { compile } from 'path-to-regexp'; -import { useMemo, type ReactNode } from 'react'; +import { useMemo, useSyncExternalStore, type ReactNode } from 'react'; import { sdk } from '../../app/utils/client/lib/SDKClient'; import { Info as info } from '../../app/utils/rocketchat.info'; -import { useReactiveValue } from '../hooks/useReactiveValue'; import { absoluteUrl } from '../lib/absoluteUrl'; import { ensureConnectedAndAuthenticated, getDdpSdk } from '../lib/sdk/ddpSdk'; import { isSdkTransportEnabled } from '../lib/sdk/sdkTransportEnabled'; @@ -99,16 +97,6 @@ const reconnect = sdkTransportEnabled } : () => Meteor.reconnect(); -// With SDK transport on, combine Meteor's DDP status with DDPSDK's so the -// ConnectionStatusBar / idle-connection hooks reflect the worst-case of both -// transports. With the flag off, route status straight through Meteor — -// Meteor.status() is already Tracker-reactive, so adding a second dependency -// via the meteor-backed proxy's emitter would just double-fire the autorun. -const ddpSdkStatusDep = sdkTransportEnabled ? new Tracker.Dependency() : undefined; -if (sdkTransportEnabled) { - getDdpSdk().connection.on('connection', () => ddpSdkStatusDep!.changed()); -} - type CombinedStatus = ReturnType; const sdkStatusToMeteor = (sdkStatus: string, meteor: CombinedStatus): CombinedStatus => { @@ -132,21 +120,48 @@ const sdkStatusToMeteor = (sdkStatus: string, meteor: CombinedStatus): CombinedS } }; -const getStatus = sdkTransportEnabled - ? () => { - ddpSdkStatusDep!.depend(); - return sdkStatusToMeteor(getDdpSdk().connection.status, Meteor.status()); - } - : // useReactiveValue stores the snapshot in useSyncExternalStore, which - // compares by identity. Meteor.status() reuses the same internal object - // (mutated in place), so without spreading the snapshot reference never - // changes and ConnectionStatusBar stops re-rendering on connect/drop. - () => ({ ...Meteor.status() }); +// With SDK transport on, combine Meteor's DDP status with DDPSDK's so the +// ConnectionStatusBar / idle-connection hooks reflect the worst-case of both +// transports. With the flag off, route status straight through Meteor — +// `meteorBackedSdk` already bridges Meteor's `_stream` events into +// `sdk.connection.on('connection')`, so the same subscription works in both +// modes. +const computeStatus: () => CombinedStatus = sdkTransportEnabled + ? () => sdkStatusToMeteor(getDdpSdk().connection.status, Meteor.status()) + : () => ({ ...Meteor.status() }); + +const isStatusEqual = (a: CombinedStatus, b: CombinedStatus): boolean => + a.status === b.status && a.connected === b.connected && a.retryCount === b.retryCount && a.retryTime === b.retryTime; + +let cachedStatus: CombinedStatus = computeStatus(); +const statusListeners = new Set<() => void>(); +let statusBridgeStarted = false; + +const ensureStatusBridge = (): void => { + if (statusBridgeStarted) return; + statusBridgeStarted = true; + getDdpSdk().connection.on('connection', () => { + const next = computeStatus(); + if (isStatusEqual(cachedStatus, next)) return; + cachedStatus = next; + statusListeners.forEach((cb) => cb()); + }); +}; + +const subscribeStatus = (cb: () => void): (() => void) => { + ensureStatusBridge(); + statusListeners.add(cb); + return () => { + statusListeners.delete(cb); + }; +}; + +const getStatusSnapshot = (): CombinedStatus => cachedStatus; type ServerProviderProps = { children?: ReactNode }; const ServerProvider = ({ children }: ServerProviderProps) => { - const { connected, status, retryCount, retryTime } = useReactiveValue(getStatus); + const { connected, status, retryCount, retryTime } = useSyncExternalStore(subscribeStatus, getStatusSnapshot); const value = useMemo( (): ServerContextValue => ({