From 93a9af6e0eb450099e1c46a015fdcf8ad8aae8d4 Mon Sep 17 00:00:00 2001 From: Martin Kellner Date: Thu, 23 Apr 2026 16:20:55 +0200 Subject: [PATCH] Low impr --- .../append/AppendAfterErrorScreen.tsx | 16 +- .../components/append/AppendInitScreen.tsx | 29 +++- .../components/login/LoginErrorScreenHard.tsx | 16 +- .../components/login/LoginErrorScreenSoft.tsx | 16 +- .../components/login/LoginHybridScreen.tsx | 18 ++- .../src/components/login/LoginInitScreen.tsx | 28 +++- .../login/LoginPasskeyReLoginScreen.tsx | 17 +- .../passkeyList/PasskeyListScreen.tsx | 11 +- .../src/hooks/useLoginInputEventLow.ts | 145 +++++++----------- .../connect-react/src/utils/inputEnvProbe.ts | 140 +++++++++++++++++ .../connect-react/src/utils/lowEventWindow.ts | 69 +++++++++ .../connect-react/src/utils/viewportBatch.ts | 55 +++++++ packages/web-core/openapi/spec_v2.yaml | 11 ++ packages/web-core/src/api/v2/api.ts | 19 +++ .../web-core/src/services/ConnectService.ts | 50 +++--- 15 files changed, 503 insertions(+), 137 deletions(-) create mode 100644 packages/connect-react/src/utils/inputEnvProbe.ts create mode 100644 packages/connect-react/src/utils/lowEventWindow.ts create mode 100644 packages/connect-react/src/utils/viewportBatch.ts diff --git a/packages/connect-react/src/components/append/AppendAfterErrorScreen.tsx b/packages/connect-react/src/components/append/AppendAfterErrorScreen.tsx index 833c0ef8c..58d15d2c2 100644 --- a/packages/connect-react/src/components/append/AppendAfterErrorScreen.tsx +++ b/packages/connect-react/src/components/append/AppendAfterErrorScreen.tsx @@ -1,21 +1,23 @@ import type { ConnectError } from '@corbado/web-core'; import { ConnectErrorType } from '@corbado/web-core'; import log from 'loglevel'; -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import useAppendProcess from '../../hooks/useAppendProcess'; import useShared from '../../hooks/useShared'; import { AppendScreenType } from '../../types/screenTypes'; import { AppendSituationCode, getAppendErrorMessage } from '../../types/situations'; +import { withLowEventWindow } from '../../utils/lowEventWindow'; import AppendAfterError from './append-init/AppendAfterError'; const AppendAfterErrorScreen = ({ attestationOptions }: { attestationOptions: string }) => { - const { navigateToScreen, handleErrorSoft, handleErrorHard, handleCredentialExistsError, handleSkip } = + const { navigateToScreen, handleErrorSoft, handleErrorHard, handleCredentialExistsError, handleSkip, flags } = useAppendProcess(); const [errorMessage, setErrorMessage] = useState(undefined); const [loading, setLoading] = useState(false); const [skipping, setSkipping] = useState(false); const { getConnectService } = useShared(); + const enableEventLow = useMemo(() => flags?.hasSupportForEventLow() ?? false, [flags]); const onSubmitClick = async () => { if (loading) { @@ -24,7 +26,15 @@ const AppendAfterErrorScreen = ({ attestationOptions }: { attestationOptions: st setLoading(true); setErrorMessage(undefined); - const res = await getConnectService().completeAppend(attestationOptions, 'manual'); + const res = await withLowEventWindow( + { + connectService: getConnectService(), + enabled: enableEventLow, + startEventType: 'pa-start', + finishEventType: 'pa-finish', + }, + () => getConnectService().completeAppend(attestationOptions, 'manual'), + ); if (res.err) { if (res.val.type === ConnectErrorType.ExcludeCredentialsMatch) { return handleSituation(AppendSituationCode.ClientExcludeCredentialsMatch, res.val); diff --git a/packages/connect-react/src/components/append/AppendInitScreen.tsx b/packages/connect-react/src/components/append/AppendInitScreen.tsx index d30b0912a..412f77808 100644 --- a/packages/connect-react/src/components/append/AppendInitScreen.tsx +++ b/packages/connect-react/src/components/append/AppendInitScreen.tsx @@ -2,13 +2,14 @@ import type { ConnectError } from '@corbado/web-core'; import { ConnectErrorType } from '@corbado/web-core'; import type { AppendCompletionType } from '@corbado/web-core/dist/models/connect/append'; import log from 'loglevel'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import useAppendProcess from '../../hooks/useAppendProcess'; import useShared from '../../hooks/useShared'; import { Flags } from '../../types/flags'; import { AppendScreenType } from '../../types/screenTypes'; import { AppendSituationCode, getAppendErrorMessage } from '../../types/situations'; +import { withLowEventWindow } from '../../utils/lowEventWindow'; import { StatefulLoader } from '../../utils/statefulLoader'; import AppendBenefits from './append-init/AppendBenetifs'; import AppendInitLoaded2 from './append-init/AppendInitLoaded2'; @@ -31,6 +32,7 @@ const AppendInitScreen = () => { handleCredentialExistsError, onReadMoreClick, setFlags, + flags, } = useAppendProcess(); const { sharedConfig, getConnectService } = useShared(); const [attestationOptions, setAttestationOptions] = useState(''); @@ -38,6 +40,7 @@ const AppendInitScreen = () => { const [appendLoading, setAppendLoading] = useState(false); const [appendInitState, setAppendInitState] = useState(AppendInitState.SilentLoading); const [skipping, setSkipping] = useState(false); + const enableEventLow = useMemo(() => flags?.hasSupportForEventLow() ?? false, [flags]); const statefulLoader = useRef( new StatefulLoader( () => setAppendInitState(AppendInitState.Loading), @@ -180,7 +183,15 @@ const AppendInitScreen = () => { setAppendLoading(true); setErrorMessage(undefined); - const res = await getConnectService().completeAppend(attestationOptions, completionType); + const res = await withLowEventWindow( + { + connectService: getConnectService(), + enabled: enableEventLow, + startEventType: 'pa-start', + finishEventType: 'pa-finish', + }, + () => getConnectService().completeAppend(attestationOptions, completionType), + ); if (res.err) { if (res.val.type === ConnectErrorType.ExcludeCredentialsMatch) { return handleSituation(AppendSituationCode.ClientExcludeCredentialsMatch, res.val); @@ -203,12 +214,20 @@ const AppendInitScreen = () => { aaguidIcon: res.val.passkeyOperation.aaguidDetails?.iconLight, }); }, - [getConnectService, appendLoading, skipping], + [getConnectService, appendLoading, skipping, enableEventLow], ); const handleConditionalCreate = useCallback( async (attestationOptions: string) => { - const res = await getConnectService().completeAppend(attestationOptions, 'conditional'); + const res = await withLowEventWindow( + { + connectService: getConnectService(), + enabled: enableEventLow, + startEventType: 'pa-start', + finishEventType: 'pa-finish', + }, + () => getConnectService().completeAppend(attestationOptions, 'conditional'), + ); if (res.err) { await handleSituation(AppendSituationCode.ClientPasskeyOperationErrorSilent, res.val); @@ -222,7 +241,7 @@ const AppendInitScreen = () => { return true; }, - [getConnectService], + [getConnectService, enableEventLow], ); const handleSituation = async (situationCode: AppendSituationCode, error?: ConnectError) => { diff --git a/packages/connect-react/src/components/login/LoginErrorScreenHard.tsx b/packages/connect-react/src/components/login/LoginErrorScreenHard.tsx index 8d871531d..33f88f57c 100644 --- a/packages/connect-react/src/components/login/LoginErrorScreenHard.tsx +++ b/packages/connect-react/src/components/login/LoginErrorScreenHard.tsx @@ -1,12 +1,13 @@ import type { ConnectError } from '@corbado/web-core'; import { ConnectErrorType, PasskeyLoginSource } from '@corbado/web-core'; import log from 'loglevel'; -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import useLoginProcess from '../../hooks/useLoginProcess'; import useShared from '../../hooks/useShared'; import { LoginScreenType } from '../../types/screenTypes'; import { getLoginErrorMessage, LoginSituationCode } from '../../types/situations'; +import { withLowEventWindow } from '../../utils/lowEventWindow'; import LoginErrorHard from './base/LoginErrorHard'; import { type CboApiFallbackOperationError, @@ -19,10 +20,11 @@ type Props = { }; const LoginErrorScreenHard = ({ previousAssertionOptions }: Props) => { - const { config, navigateToScreen, currentIdentifier, loadedMs, fallback } = useLoginProcess(); + const { config, navigateToScreen, currentIdentifier, loadedMs, fallback, flags } = useLoginProcess(); const { getConnectService } = useShared(); const [loading, setLoading] = useState(false); const [hardErrorCount, setHardErrorCount] = useState(1); + const enableEventLow = useMemo(() => flags?.hasSupportForEventLow() ?? false, [flags]); // only for logging purposes const [assertionOptions, setAssertionOptions] = useState(previousAssertionOptions); @@ -49,7 +51,15 @@ const LoginErrorScreenHard = ({ previousAssertionOptions }: Props) => { setAssertionOptions(resStart.val.assertionOptions); - const resFinish = await getConnectService().loginContinue(resStart.val); + const resFinish = await withLowEventWindow( + { + connectService: getConnectService(), + enabled: enableEventLow, + startEventType: 'pl-start', + finishEventType: 'pl-finish', + }, + () => getConnectService().loginContinue(resStart.val), + ); if (resFinish.err) { if (resFinish.val.type === ConnectErrorType.Cancel) { if (hardErrorCount >= 3) { diff --git a/packages/connect-react/src/components/login/LoginErrorScreenSoft.tsx b/packages/connect-react/src/components/login/LoginErrorScreenSoft.tsx index 7ceb63c20..7d81d59c4 100644 --- a/packages/connect-react/src/components/login/LoginErrorScreenSoft.tsx +++ b/packages/connect-react/src/components/login/LoginErrorScreenSoft.tsx @@ -1,12 +1,13 @@ import type { ConnectError } from '@corbado/web-core'; import { ConnectErrorType, PasskeyLoginSource } from '@corbado/web-core'; import log from 'loglevel'; -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import useLoginProcess from '../../hooks/useLoginProcess'; import useShared from '../../hooks/useShared'; import { LoginScreenType } from '../../types/screenTypes'; import { getLoginErrorMessage, LoginSituationCode } from '../../types/situations'; +import { withLowEventWindow } from '../../utils/lowEventWindow'; import LoginErrorSoft from './base/LoginErrorSoft'; import type { CboApiFallbackOperationError } from './LoginInitScreen'; import { connectLoginFinishToComplete, connectLoginFinishToWebauthnId } from './LoginInitScreen'; @@ -16,9 +17,10 @@ type Props = { }; const LoginErrorScreenSoft = ({ previousAssertionOptions }: Props) => { - const { config, navigateToScreen, currentIdentifier, loadedMs, fallback } = useLoginProcess(); + const { config, navigateToScreen, currentIdentifier, loadedMs, fallback, flags } = useLoginProcess(); const { getConnectService } = useShared(); const [loading, setLoading] = useState(false); + const enableEventLow = useMemo(() => flags?.hasSupportForEventLow() ?? false, [flags]); // only for logging purposes const handleSubmit = async () => { @@ -42,7 +44,15 @@ const LoginErrorScreenSoft = ({ previousAssertionOptions }: Props) => { return handleSituation(LoginSituationCode.CboApiFallbackOperationError, undefined, data); } - const resFinish = await getConnectService().loginContinue(resStart.val); + const resFinish = await withLowEventWindow( + { + connectService: getConnectService(), + enabled: enableEventLow, + startEventType: 'pl-start', + finishEventType: 'pl-finish', + }, + () => getConnectService().loginContinue(resStart.val), + ); if (resFinish.err) { if (resFinish.val.type === ConnectErrorType.Cancel || resFinish.val.type === ConnectErrorType.Untyped) { return handleSituation( diff --git a/packages/connect-react/src/components/login/LoginHybridScreen.tsx b/packages/connect-react/src/components/login/LoginHybridScreen.tsx index 2a059e142..b3600b9a4 100644 --- a/packages/connect-react/src/components/login/LoginHybridScreen.tsx +++ b/packages/connect-react/src/components/login/LoginHybridScreen.tsx @@ -2,19 +2,21 @@ import type { ConnectError } from '@corbado/web-core'; import { ConnectErrorType } from '@corbado/web-core'; import type { ConnectLoginStartRsp } from '@corbado/web-core/dist/api/v2'; import log from 'loglevel'; -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import useLoginProcess from '../../hooks/useLoginProcess'; import useShared from '../../hooks/useShared'; import { LoginScreenType } from '../../types/screenTypes'; import { getLoginErrorMessage, LoginSituationCode } from '../../types/situations'; +import { withLowEventWindow } from '../../utils/lowEventWindow'; import LoginHybrid from './base/LoginHybrid'; import { connectLoginFinishToComplete, connectLoginFinishToWebauthnId } from './LoginInitScreen'; const LoginHybridScreen = (resStart: ConnectLoginStartRsp) => { - const { config, navigateToScreen, currentIdentifier, fallback } = useLoginProcess(); + const { config, navigateToScreen, currentIdentifier, fallback, flags } = useLoginProcess(); const [loading, setLoading] = useState(false); const { getConnectService } = useShared(); + const enableEventLow = useMemo(() => flags?.hasSupportForEventLow() ?? false, [flags]); const handleSubmit = useCallback(async () => { if (loading) { @@ -22,7 +24,15 @@ const LoginHybridScreen = (resStart: ConnectLoginStartRsp) => { } setLoading(true); - const res = await getConnectService().loginContinue(resStart); + const res = await withLowEventWindow( + { + connectService: getConnectService(), + enabled: enableEventLow, + startEventType: 'pl-start', + finishEventType: 'pl-finish', + }, + () => getConnectService().loginContinue(resStart), + ); if (res.err) { if (res.val.type === ConnectErrorType.Cancel || res.val.type === ConnectErrorType.Untyped) { return handleSituation(LoginSituationCode.ClientPasskeyOperationCancelled, res.val); @@ -40,7 +50,7 @@ const LoginHybridScreen = (resStart: ConnectLoginStartRsp) => { } catch { return handleSituation(LoginSituationCode.CtApiNotAvailablePostAuthenticator); } - }, [getConnectService, config, navigateToScreen, currentIdentifier, loading]); + }, [getConnectService, config, navigateToScreen, currentIdentifier, loading, enableEventLow]); const handleSituation = (situationCode: LoginSituationCode, error?: ConnectError) => { const messageCode = `situation: ${situationCode} ${error?.track()}`; diff --git a/packages/connect-react/src/components/login/LoginInitScreen.tsx b/packages/connect-react/src/components/login/LoginInitScreen.tsx index 8efb6e8ad..0ae5c61d6 100644 --- a/packages/connect-react/src/components/login/LoginInitScreen.tsx +++ b/packages/connect-react/src/components/login/LoginInitScreen.tsx @@ -10,6 +10,7 @@ import useShared from '../../hooks/useShared'; import { Flags } from '../../types/flags'; import { LoginScreenType } from '../../types/screenTypes'; import { getLoginErrorMessage, LoginSituationCode } from '../../types/situations'; +import { withLowEventWindow } from '../../utils/lowEventWindow'; import { StatefulLoader } from '../../utils/statefulLoader'; import LoginInitLoaded from './base/LoginInitLoaded'; import LoginInitLoading from './base/LoginInitLoading'; @@ -126,7 +127,13 @@ const LoginInitScreen: FC = ({ showFallback = false }) => { if (lastLogin) { log.debug('starting relogin UI'); return navigateToScreen(LoginScreenType.PasskeyReLogin); - } else if (flags.hasSupportForConditionalUI()) { + } + + if (flags.hasSupportForEventLow()) { + getConnectService().enqueueLowEvent({ eventType: 'li-ready', timestamp: Date.now() }); + } + + if (flags.hasSupportForConditionalUI()) { log.debug('starting conditional UI'); void startConditionalUI(res.val.conditionalUIChallenge, flags); } @@ -179,6 +186,11 @@ const LoginInitScreen: FC = ({ showFallback = false }) => { return handleSituation(LoginSituationCode.CboApiNotAvailablePreConditionalAuthenticator, res.val); } + if (resolvedFlags.hasSupportForEventLow()) { + getConnectService().enqueueLowEvent({ eventType: 'cui-finish', timestamp: Date.now() }); + } + await getConnectService().flushLowEvents(); + if (res.val.fallbackOperationError) { const data: CboApiFallbackOperationError = { initFallback: res.val.fallbackOperationError.initFallback, @@ -191,7 +203,6 @@ const LoginInitScreen: FC = ({ showFallback = false }) => { } try { - await getConnectService().flushLowEvents(); await config.onComplete( connectLoginFinishToComplete(res.val), getConnectService().encodeClientState(), @@ -209,6 +220,9 @@ const LoginInitScreen: FC = ({ showFallback = false }) => { setIdentifierBasedLoading(true); setCurrentIdentifier(identifier); + if (enableEventLow) { + getConnectService().enqueueLowEvent({ eventType: 'li-finish', timestamp: Date.now() }); + } await getConnectService().flushLowEvents(); config.onLoginStart?.(); @@ -233,7 +247,15 @@ const LoginInitScreen: FC = ({ showFallback = false }) => { return handleSituation(LoginSituationCode.CboApiFallbackOperationError, undefined, data); } - const res = await getConnectService().loginContinue(resStart.val); + const res = await withLowEventWindow( + { + connectService: getConnectService(), + enabled: enableEventLow, + startEventType: 'pl-start', + finishEventType: 'pl-finish', + }, + () => getConnectService().loginContinue(resStart.val), + ); if (res.err) { setIdentifierBasedLoading(false); if (res.val.type === ConnectErrorType.Cancel || res.val.type === ConnectErrorType.Untyped) { diff --git a/packages/connect-react/src/components/login/LoginPasskeyReLoginScreen.tsx b/packages/connect-react/src/components/login/LoginPasskeyReLoginScreen.tsx index d28100dcc..67df9afd0 100644 --- a/packages/connect-react/src/components/login/LoginPasskeyReLoginScreen.tsx +++ b/packages/connect-react/src/components/login/LoginPasskeyReLoginScreen.tsx @@ -1,12 +1,13 @@ import type { ConnectError } from '@corbado/web-core'; import { ConnectErrorType, PasskeyLoginSource } from '@corbado/web-core'; import log from 'loglevel'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import useLoginProcess from '../../hooks/useLoginProcess'; import useShared from '../../hooks/useShared'; import { LoginScreenType } from '../../types/screenTypes'; import { getLoginErrorMessage, LoginSituationCode } from '../../types/situations'; +import { withLowEventWindow } from '../../utils/lowEventWindow'; import LoginOneTap from './base/LoginOneTap'; import { type CboApiFallbackOperationError, @@ -15,9 +16,11 @@ import { } from './LoginInitScreen'; export const LoginPasskeyReLoginScreen = () => { - const { config, navigateToScreen, setCurrentIdentifier, currentIdentifier, loadedMs, fallback } = useLoginProcess(); + const { config, navigateToScreen, setCurrentIdentifier, currentIdentifier, loadedMs, fallback, flags } = + useLoginProcess(); const { getConnectService } = useShared(); const [loading, setLoading] = useState(false); + const enableEventLow = useMemo(() => flags?.hasSupportForEventLow() ?? false, [flags]); useEffect(() => { const lastLogin = getConnectService().getLastLogin(); @@ -47,7 +50,15 @@ export const LoginPasskeyReLoginScreen = () => { return handleSituation(LoginSituationCode.CboApiFallbackOperationError, undefined, data); } - const resFinish = await getConnectService().loginContinue(resStart.val); + const resFinish = await withLowEventWindow( + { + connectService: getConnectService(), + enabled: enableEventLow, + startEventType: 'pl-start', + finishEventType: 'pl-finish', + }, + () => getConnectService().loginContinue(resStart.val), + ); if (resFinish.err) { if (resFinish.val.type === ConnectErrorType.Cancel || resFinish.val.type === ConnectErrorType.Untyped) { return handleSituation(LoginSituationCode.ClientPasskeyOperationCancelled, resFinish.val); diff --git a/packages/connect-react/src/components/passkeyList/PasskeyListScreen.tsx b/packages/connect-react/src/components/passkeyList/PasskeyListScreen.tsx index 386a10d9e..de1030998 100644 --- a/packages/connect-react/src/components/passkeyList/PasskeyListScreen.tsx +++ b/packages/connect-react/src/components/passkeyList/PasskeyListScreen.tsx @@ -9,6 +9,7 @@ import useModal from '../../hooks/useModal'; import useShared from '../../hooks/useShared'; import { getPasskeyListErrorMessage, PasskeyListSituationCode } from '../../types/situations'; import { ConnectTokenType } from '../../types/tokens'; +import { withLowEventWindow } from '../../utils/lowEventWindow'; import { StatefulLoader } from '../../utils/statefulLoader'; import AlreadyExistingModal from './AlreadyExistingModal'; import DeleteModal from './DeleteModal'; @@ -130,7 +131,15 @@ const PasskeyListScreen = () => { return handleSituation(PasskeyListSituationCode.CboApiPasskeysNotSupported); } - const res = await getConnectService().completeAppend(startAppendRes.val.attestationOptions, 'manual'); + const res = await withLowEventWindow( + { + connectService: getConnectService(), + enabled: true, + startEventType: 'pa-start', + finishEventType: 'pa-finish', + }, + () => getConnectService().completeAppend(startAppendRes.val.attestationOptions, 'manual'), + ); if (res.err) { if (res.val.type === ConnectErrorType.Cancel) { return handleSituation(PasskeyListSituationCode.ClientPasskeyOperationCancelled, res.val); diff --git a/packages/connect-react/src/hooks/useLoginInputEventLow.ts b/packages/connect-react/src/hooks/useLoginInputEventLow.ts index 7d661a067..1769e067f 100644 --- a/packages/connect-react/src/hooks/useLoginInputEventLow.ts +++ b/packages/connect-react/src/hooks/useLoginInputEventLow.ts @@ -2,6 +2,9 @@ import type { ConnectService } from '@corbado/web-core'; import type { RefObject } from 'react'; import { useEffect, useRef } from 'react'; +import { scanInputEnvSignals } from '../utils/inputEnvProbe'; +import { createViewportBatcher } from '../utils/viewportBatch'; + type Props = { inputRef: RefObject; connectService: ConnectService; @@ -13,16 +16,8 @@ type InputBatch = { lastTimestamp: number; }; -const visualViewportBatchTimeoutMs = 150; - const useLoginInputEventLow = ({ inputRef, connectService, enabled }: Props) => { const inputBatchRef = useRef(null); - const viewportResizeActiveRef = useRef(false); - const viewportResizeStartTimestampRef = useRef(null); - const viewportResizeEndTimeoutRef = useRef(null); - const viewportScrollActiveRef = useRef(false); - const viewportScrollStartTimestampRef = useRef(null); - const viewportScrollEndTimeoutRef = useRef(null); useEffect(() => { if (!enabled) { @@ -36,6 +31,36 @@ const useLoginInputEventLow = ({ inputRef, connectService, enabled }: Props) => const isInputFocused = () => document.activeElement === input; + const resizeBatcher = createViewportBatcher(connectService, 'visualviewport-resize'); + const scrollBatcher = createViewportBatcher(connectService, 'visualviewport-scroll'); + + const emittedSignals = new Set(); + let firstFocusHandled = false; + let mountProbeTimer: number | null = null; + let focusProbeTimer: number | null = null; + + const runEnvProbe = () => { + const matches = scanInputEnvSignals(); + const now = Date.now(); + for (const eventType of matches) { + if (emittedSignals.has(eventType)) { + continue; + } + + emittedSignals.add(eventType); + connectService.enqueueLowEvent({ eventType, timestamp: now }); + } + }; + + const scheduleFocusProbe = () => { + if (firstFocusHandled) { + return; + } + + firstFocusHandled = true; + focusProbeTimer = window.setTimeout(runEnvProbe, 250); + }; + const flushInputBatch = () => { const batch = inputBatchRef.current; if (!batch) { @@ -58,69 +83,14 @@ const useLoginInputEventLow = ({ inputRef, connectService, enabled }: Props) => }); }; - const flushViewportBatch = ( - eventType: 'visualviewport-resize' | 'visualviewport-scroll', - activeRef: { current: boolean }, - startTimestampRef: { current: number | null }, - timeoutRef: { current: number | null }, - ) => { - if (timeoutRef.current !== null) { - window.clearTimeout(timeoutRef.current); - } - - const startTimestamp = startTimestampRef.current; - activeRef.current = false; - startTimestampRef.current = null; - timeoutRef.current = null; - - if (startTimestamp === null) { - return; - } - - connectService.enqueueLowEvent({ - eventType, - timestamp: startTimestamp, - durationMs: Date.now() - startTimestamp, - }); - }; - - const extendViewportBatch = ( - eventType: 'visualviewport-resize' | 'visualviewport-scroll', - activeRef: { current: boolean }, - startTimestampRef: { current: number | null }, - timeoutRef: { current: number | null }, - ) => { - if (!activeRef.current) { - activeRef.current = true; - startTimestampRef.current = Date.now(); - } - - if (timeoutRef.current !== null) { - window.clearTimeout(timeoutRef.current); - } - - timeoutRef.current = window.setTimeout(() => { - flushViewportBatch(eventType, activeRef, startTimestampRef, timeoutRef); - }, visualViewportBatchTimeoutMs); - }; - const handleFocus = () => { + scheduleFocusProbe(); enqueueNonInputEvent('focus'); }; const handleBlur = () => { - flushViewportBatch( - 'visualviewport-resize', - viewportResizeActiveRef, - viewportResizeStartTimestampRef, - viewportResizeEndTimeoutRef, - ); - flushViewportBatch( - 'visualviewport-scroll', - viewportScrollActiveRef, - viewportScrollStartTimestampRef, - viewportScrollEndTimeoutRef, - ); + resizeBatcher.flush(); + scrollBatcher.flush(); enqueueNonInputEvent('blur'); }; @@ -180,7 +150,11 @@ const useLoginInputEventLow = ({ inputRef, connectService, enabled }: Props) => return; } - enqueueNonInputEvent('document-visibilitychange'); + const eventType = + document.visibilityState === 'hidden' + ? 'document-visibilitychange-hidden' + : 'document-visibilitychange-visible'; + enqueueNonInputEvent(eventType); }; const handleVisualViewportResize = () => { @@ -188,12 +162,7 @@ const useLoginInputEventLow = ({ inputRef, connectService, enabled }: Props) => return; } - extendViewportBatch( - 'visualviewport-resize', - viewportResizeActiveRef, - viewportResizeStartTimestampRef, - viewportResizeEndTimeoutRef, - ); + resizeBatcher.extend(); }; const handleVisualViewportScroll = () => { @@ -201,28 +170,13 @@ const useLoginInputEventLow = ({ inputRef, connectService, enabled }: Props) => return; } - extendViewportBatch( - 'visualviewport-scroll', - viewportScrollActiveRef, - viewportScrollStartTimestampRef, - viewportScrollEndTimeoutRef, - ); + scrollBatcher.extend(); }; const flushForTeardown = () => { flushInputBatch(); - flushViewportBatch( - 'visualviewport-resize', - viewportResizeActiveRef, - viewportResizeStartTimestampRef, - viewportResizeEndTimeoutRef, - ); - flushViewportBatch( - 'visualviewport-scroll', - viewportScrollActiveRef, - viewportScrollStartTimestampRef, - viewportScrollEndTimeoutRef, - ); + resizeBatcher.flush(); + scrollBatcher.flush(); connectService.flushLowEventsKeepalive(); }; @@ -245,7 +199,16 @@ const useLoginInputEventLow = ({ inputRef, connectService, enabled }: Props) => handleFocus(); } + mountProbeTimer = window.setTimeout(runEnvProbe, 500); + return () => { + if (mountProbeTimer !== null) { + window.clearTimeout(mountProbeTimer); + } + if (focusProbeTimer !== null) { + window.clearTimeout(focusProbeTimer); + } + flushForTeardown(); input.removeEventListener('focus', handleFocus); diff --git a/packages/connect-react/src/utils/inputEnvProbe.ts b/packages/connect-react/src/utils/inputEnvProbe.ts new file mode 100644 index 000000000..fc1a9a85f --- /dev/null +++ b/packages/connect-react/src/utils/inputEnvProbe.ts @@ -0,0 +1,140 @@ +type ScanRoot = Document | ShadowRoot; + +type TagQueryEntry = { + query: string; + eventType: string; +}; + +const SIGNAL_TAG_QUERIES: TagQueryEntry[] = [ + { query: 'com-1password-button', eventType: 'pa-1' }, + { query: 'com-1password-menu', eventType: 'pa-2' }, + { query: 'com-1password-notification', eventType: 'pa-3' }, +]; + +const SIGNAL_ATTR_SELECTOR = '[data-lastpass-root], [data-lp], [data-lpignore], [data-lpfieldtype]'; +const SIGNAL_ATTR_EVENT_TYPE = 'pa-4'; + +const POPOVER_MANUAL_SELECTOR = '[popover="manual"]'; + +const PROFILE_A_MIN_STYLE_LENGTH = 400; +const PROFILE_A_MIN_INITIAL_IMPORTANT = 20; +const PROFILE_A_EVENT_TYPE = 'pa-5'; +const PROFILE_B_EVENT_TYPE = 'pa-6'; + +const safeQueryAll = (root: ScanRoot, selector: string): Element[] => { + try { + return Array.from(root.querySelectorAll(selector)); + } catch { + return []; + } +}; + +const isPopoverProfileA = (el: Element): boolean => { + if (el.getAttribute('popover') !== 'manual') { + return false; + } + + const style = el.getAttribute('style') ?? ''; + if (style.length < PROFILE_A_MIN_STYLE_LENGTH) { + return false; + } + + if (!/z-index:\s*2147483647\b/i.test(style)) { + return false; + } + + if (!/position:\s*fixed\b/i.test(style)) { + return false; + } + + if (!/display:\s*block\b/i.test(style)) { + return false; + } + + const initialImportantHits = style.match(/initial\s*!important/gi); + if ((initialImportantHits?.length ?? 0) < PROFILE_A_MIN_INITIAL_IMPORTANT) { + return false; + } + + return true; +}; + +const isPopoverProfileB = (el: Element): boolean => { + if (isPopoverProfileA(el)) { + return false; + } + + if (el.getAttribute('popover') !== 'manual') { + return false; + } + + const style = el.getAttribute('style') ?? ''; + + if (!/z-index:\s*2147483647\b/i.test(style)) { + return false; + } + + if (!/position:\s*fixed\b/i.test(style)) { + return false; + } + + if (!/backdrop-filter:\s*[^;]*blur\(\s*20px\s*\)/i.test(style)) { + return false; + } + + if (!/background-color:\s*rgba\(\s*35\s*,\s*35\s*,\s*35\s*,/i.test(style)) { + return false; + } + + return true; +}; + +const forEachScanRoot = (cb: (root: ScanRoot) => void): void => { + cb(document); + + for (const host of Array.from(document.querySelectorAll('*'))) { + const shadowRoot = (host as Element & { shadowRoot?: ShadowRoot | null }).shadowRoot; + if (shadowRoot) { + cb(shadowRoot); + } + } +}; + +export const scanInputEnvSignals = (): Set => { + const matched = new Set(); + + if (typeof document === 'undefined') { + return matched; + } + + forEachScanRoot(root => { + for (const entry of SIGNAL_TAG_QUERIES) { + if (matched.has(entry.eventType)) { + continue; + } + + if (safeQueryAll(root, entry.query).length > 0) { + matched.add(entry.eventType); + } + } + + if (!matched.has(SIGNAL_ATTR_EVENT_TYPE) && safeQueryAll(root, SIGNAL_ATTR_SELECTOR).length > 0) { + matched.add(SIGNAL_ATTR_EVENT_TYPE); + } + + if (!matched.has(PROFILE_A_EVENT_TYPE) || !matched.has(PROFILE_B_EVENT_TYPE)) { + for (const el of safeQueryAll(root, POPOVER_MANUAL_SELECTOR)) { + if (!matched.has(PROFILE_A_EVENT_TYPE) && isPopoverProfileA(el)) { + matched.add(PROFILE_A_EVENT_TYPE); + continue; + } + + if (!matched.has(PROFILE_B_EVENT_TYPE) && isPopoverProfileB(el)) { + matched.add(PROFILE_B_EVENT_TYPE); + } + } + } + }); + + return matched; +}; diff --git a/packages/connect-react/src/utils/lowEventWindow.ts b/packages/connect-react/src/utils/lowEventWindow.ts new file mode 100644 index 000000000..5c1b2b220 --- /dev/null +++ b/packages/connect-react/src/utils/lowEventWindow.ts @@ -0,0 +1,69 @@ +import type { ConnectService } from '@corbado/web-core'; + +import { createViewportBatcher } from './viewportBatch'; + +export type LowEventWindowOptions = { + connectService: ConnectService; + enabled: boolean; + startEventType: string; + finishEventType: string; +}; + +const installLowEventWindowListeners = (connectService: ConnectService): (() => void) => { + const resizeBatcher = createViewportBatcher(connectService, 'visualviewport-resize'); + const scrollBatcher = createViewportBatcher(connectService, 'visualviewport-scroll'); + + const enqueueNow = (eventType: string) => { + connectService.enqueueLowEvent({ eventType, timestamp: Date.now() }); + }; + + const handleWindowFocus = () => enqueueNow('window-focus'); + const handleWindowBlur = () => enqueueNow('window-blur'); + const handleVisibilityChange = () => { + const eventType = + document.visibilityState === 'hidden' ? 'document-visibilitychange-hidden' : 'document-visibilitychange-visible'; + enqueueNow(eventType); + }; + const handleResize = () => resizeBatcher.extend(); + const handleScroll = () => scrollBatcher.extend(); + + window.addEventListener('focus', handleWindowFocus); + window.addEventListener('blur', handleWindowBlur); + document.addEventListener('visibilitychange', handleVisibilityChange); + window.visualViewport?.addEventListener('resize', handleResize); + window.visualViewport?.addEventListener('scroll', handleScroll); + + return () => { + window.removeEventListener('focus', handleWindowFocus); + window.removeEventListener('blur', handleWindowBlur); + document.removeEventListener('visibilitychange', handleVisibilityChange); + window.visualViewport?.removeEventListener('resize', handleResize); + window.visualViewport?.removeEventListener('scroll', handleScroll); + + resizeBatcher.flush(); + scrollBatcher.flush(); + }; +}; + +export const withLowEventWindow = async (options: LowEventWindowOptions, fn: () => Promise): Promise => { + if (!options.enabled) { + return fn(); + } + + const stop = installLowEventWindowListeners(options.connectService); + options.connectService.enqueueLowEvent({ + eventType: options.startEventType, + timestamp: Date.now(), + }); + + try { + return await fn(); + } finally { + stop(); + options.connectService.enqueueLowEvent({ + eventType: options.finishEventType, + timestamp: Date.now(), + }); + await options.connectService.flushLowEvents(); + } +}; diff --git a/packages/connect-react/src/utils/viewportBatch.ts b/packages/connect-react/src/utils/viewportBatch.ts new file mode 100644 index 000000000..e498b4d1a --- /dev/null +++ b/packages/connect-react/src/utils/viewportBatch.ts @@ -0,0 +1,55 @@ +import type { ConnectService } from '@corbado/web-core'; + +const VIEWPORT_BATCH_TIMEOUT_MS = 150; + +export type ViewportEventType = 'visualviewport-resize' | 'visualviewport-scroll'; + +export type ViewportBatcher = { + extend: () => void; + flush: () => void; +}; + +export const createViewportBatcher = ( + connectService: ConnectService, + eventType: ViewportEventType, +): ViewportBatcher => { + let active = false; + let startTimestamp: number | null = null; + let timeout: number | null = null; + + const flush = () => { + if (timeout !== null) { + window.clearTimeout(timeout); + timeout = null; + } + + const ts = startTimestamp; + active = false; + startTimestamp = null; + + if (ts === null) { + return; + } + + connectService.enqueueLowEvent({ + eventType, + timestamp: ts, + durationMs: Date.now() - ts, + }); + }; + + const extend = () => { + if (!active) { + active = true; + startTimestamp = Date.now(); + } + + if (timeout !== null) { + window.clearTimeout(timeout); + } + + timeout = window.setTimeout(flush, VIEWPORT_BATCH_TIMEOUT_MS); + }; + + return { extend, flush }; +}; diff --git a/packages/web-core/openapi/spec_v2.yaml b/packages/web-core/openapi/spec_v2.yaml index b5d4e5dbd..b82b2cca1 100644 --- a/packages/web-core/openapi/spec_v2.yaml +++ b/packages/web-core/openapi/spec_v2.yaml @@ -1782,6 +1782,17 @@ components: type: array items: $ref: "#/components/schemas/connectEventLow" + meta: + $ref: "#/components/schemas/connectEventLowMeta" + + connectEventLowMeta: + type: object + required: + - sent + properties: + sent: + type: integer + format: int64 connectEventLow: type: object diff --git a/packages/web-core/src/api/v2/api.ts b/packages/web-core/src/api/v2/api.ts index 6afaa36cf..064632211 100644 --- a/packages/web-core/src/api/v2/api.ts +++ b/packages/web-core/src/api/v2/api.ts @@ -726,6 +726,25 @@ export interface ConnectEventLowCreateReq { * @memberof ConnectEventLowCreateReq */ 'items': Array; + /** + * + * @type {ConnectEventLowMeta} + * @memberof ConnectEventLowCreateReq + */ + 'meta'?: ConnectEventLowMeta; +} +/** + * + * @export + * @interface ConnectEventLowMeta + */ +export interface ConnectEventLowMeta { + /** + * + * @type {number} + * @memberof ConnectEventLowMeta + */ + 'sent': number; } /** * diff --git a/packages/web-core/src/services/ConnectService.ts b/packages/web-core/src/services/ConnectService.ts index 10f5df22e..9c416f12d 100644 --- a/packages/web-core/src/services/ConnectService.ts +++ b/packages/web-core/src/services/ConnectService.ts @@ -44,6 +44,8 @@ import { WebAuthnService } from './WebAuthnService'; const packageVersion = process.env.FE_LIBRARY_VERSION; +const LOW_EVENT_FLUSH_MAX_WAIT_MS = 2000; + interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig { metadata?: { startTime?: number; @@ -514,9 +516,7 @@ export class ConnectService { } async flushLowEvents(): Promise { - if (this.#lowEventFlushPromise) { - await this.#lowEventFlushPromise; - } + await this.#awaitCapped(this.#lowEventFlushPromise); const groups = this.#takeQueuedLowEventGroups(); if (groups.length === 0) { @@ -524,31 +524,31 @@ export class ConnectService { } const flushPromise = (async () => { - const failedItems: QueuedLowEvent[] = []; - for (const group of groups) { try { await this.#sendQueuedLowEventGroup(group); } catch (error) { log.debug('failed to flush low events', error); - failedItems.push(...group.queuedItems); } } - - if (failedItems.length > 0) { - this.#lowEventQueue = failedItems.concat(this.#lowEventQueue); - } })(); this.#lowEventFlushPromise = flushPromise; - - try { - await flushPromise; - } finally { + void flushPromise.finally(() => { if (this.#lowEventFlushPromise === flushPromise) { this.#lowEventFlushPromise = null; } + }); + + await this.#awaitCapped(flushPromise); + } + + async #awaitCapped(promise: Promise | null): Promise { + if (!promise) { + return; } + + await Promise.race([promise, new Promise(resolve => setTimeout(resolve, LOW_EVENT_FLUSH_MAX_WAIT_MS))]); } flushLowEventsKeepalive() { @@ -834,12 +834,26 @@ export class ConnectService { timestamp, durationMs, })), + meta: { + sent: Date.now() + 1000, + }, }; await api.connectEventLowCreate(req); } async #sendQueuedLowEventGroupKeepalive(group: QueuedLowEventGroup): Promise { + const body: ConnectEventLowCreateReq = { + items: group.queuedItems.map(({ eventType, timestamp, durationMs }) => ({ + eventType, + timestamp, + durationMs, + })), + meta: { + sent: Date.now(), + }, + }; + const response = await fetch(this.#getConnectEventLowUrl(group.frontendApiUrl), { method: 'POST', keepalive: true, @@ -854,13 +868,7 @@ export class ConnectService { }), 'x-corbado-process-id': group.processId, }, - body: JSON.stringify({ - items: group.queuedItems.map(({ eventType, timestamp, durationMs }) => ({ - eventType, - timestamp, - durationMs, - })), - } as ConnectEventLowCreateReq), + body: JSON.stringify(body), }); if (!response.ok) {