diff --git a/CHANGELOG.md b/CHANGELOG.md index 0db70add..775bf87d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +v1.4.6 +- +- Устранены проблемы, связанные с работой runSync, все переданные переходы, выполняются + v1.4.5 - - Устранена проблема с синхронизацией состояния роутера при многократном вызове хуков diff --git a/examples/vk-mini-apps-router-example/src/onboarding/OnboardingThree.tsx b/examples/vk-mini-apps-router-example/src/onboarding/OnboardingThree.tsx index ae4a04c1..298e52e4 100644 --- a/examples/vk-mini-apps-router-example/src/onboarding/OnboardingThree.tsx +++ b/examples/vk-mini-apps-router-example/src/onboarding/OnboardingThree.tsx @@ -8,9 +8,8 @@ export const OnboardingThree = ({ nav }: { nav: string }) => { const onClick = async () => { routeNavigator.runSync([ () => routeNavigator.backToFirst(), - () => routeNavigator.replace('/'), - () => routeNavigator.push('/'), - () => routeNavigator.back(), + () => routeNavigator.push('/persik'), + () => routeNavigator.push('/persik/persik_modal'), ]); }; return ( @@ -19,7 +18,10 @@ export const OnboardingThree = ({ nav }: { nav: string }) => {
Когда персик огорчен, он выглядит так:
Persik The Cat - Закончить +
1) Возвращаемся в начало навигации
+
2) /persik
+
3) /persik/persik_modal
+ Выполнить runSync
); diff --git a/package.json b/package.json index 6b9add74..288660ae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@vkontakte/vk-mini-apps-router", - "version": "1.4.5", + "version": "1.4.6", "description": "React-роутер для мини-приложений ВКонтакте, построенных на VKUI", "main": "./dist/index.js", "typings": "./dist/index.d.ts", diff --git a/src/components/RouterProvider.tsx b/src/components/RouterProvider.tsx index a41bd2ca..e36ffd93 100644 --- a/src/components/RouterProvider.tsx +++ b/src/components/RouterProvider.tsx @@ -1,20 +1,19 @@ -import { Action, Router } from '@remix-run/router'; -import { PopoutContext, RouteContext, RouterContext } from '../contexts'; import { ReactElement, ReactNode, useEffect, useLayoutEffect, useMemo, useState } from 'react'; -import { DefaultRouteNavigator } from '../services/DefaultRouteNavigator'; +import { Action, Router } from '@remix-run/router'; import bridge from '@vkontakte/vk-bridge'; -import { DefaultNotFound } from './DefaultNotFound'; -import { getRouteContext, useForceUpdate } from '../utils/utils'; -import { ViewHistory } from '../services/ViewHistory'; -import { useBlockForwardToModals } from '../hooks/useBlockForwardToModals'; import { SEARCH_PARAM_INFLATE, STATE_KEY_SHOW_POPOUT, UNIVERSAL_URL } from '../const'; -import { RouteNavigator } from '../services/RouteNavigator.type'; -import { TransactionExecutor } from '../services/TransactionExecutor'; -import { fillHistory } from '../utils/fillHistory'; -import { createSearchParams } from '../utils/createSearchParams'; +import { PopoutContext, RouteContext, RouterContext } from '../contexts'; +import { getRouteContext, fillHistory, createSearchParams, getHrefWithoutHash } from '../utils'; +import { + DefaultRouteNavigator, + ContextThrottleService, + TransactionExecutor, + RouteNavigator, + ViewHistory, +} from '../services'; +import { useBlockForwardToModals } from '../hooks/useBlockForwardToModals'; +import { DefaultNotFound } from './DefaultNotFound'; import { RouteLeaf } from '../type'; -import { getHrefWithoutHash } from '../utils/getHrefWithoutHash'; -import { ContextThrottleService } from '../services/ContextThrottleService'; export interface RouterProviderProps { router: Router; @@ -37,24 +36,19 @@ export function RouterProvider({ useBridge = true, throttled = true, }: RouterProviderProps): ReactElement { - const forceUpdate = useForceUpdate(); const [popout, setPopout] = useState(null); const [viewHistory] = useState(new ViewHistory()); const [panelsHistory, setPanelsHistory] = useState([]); - const [transactionExecutor, setTransactionExecutor] = useState( - new TransactionExecutor(forceUpdate), - ); const isPopoutShown = router.state.location.state?.[STATE_KEY_SHOW_POPOUT]; const dataRouterContext = useMemo(() => { const routeNavigator: RouteNavigator = new DefaultRouteNavigator( router, viewHistory, - transactionExecutor, setPopout, ); return { router, routeNavigator, viewHistory }; - }, [router, viewHistory, transactionExecutor, setPopout]); + }, [router, viewHistory, setPopout]); const routeContext = useMemo( () => getRouteContext(router.state, panelsHistory), @@ -70,6 +64,7 @@ export function RouterProvider({ // Отключаем браузерное восстановление скролла, используем решения от VKUI history.scrollRestoration = 'manual'; + TransactionExecutor.resetTransactions(); viewHistory.resetHistory(); viewHistory.updateNavigation({ ...router.state, historyAction: Action.Push }); setPanelsHistory(viewHistory.panelsHistory); @@ -77,7 +72,7 @@ export function RouterProvider({ router.subscribe((state) => { viewHistory.updateNavigation(state); setPanelsHistory(viewHistory.panelsHistory); - transactionExecutor.doNext(); + TransactionExecutor.doNext(); }); if (useBridge) { @@ -86,31 +81,28 @@ export function RouterProvider({ router.navigate(event.detail.data.location, { replace: true }); } }); + router.subscribe((state) => { const href = router.createHref(state.location); const hrefWithoutHash = getHrefWithoutHash(); const location = href.replace(hrefWithoutHash, '').replace(/^#/, ''); - bridge.send('VKWebAppSetLocation', { location, replace_state: true }); }); } - const executor = new TransactionExecutor(forceUpdate); - setTransactionExecutor(executor); const searchParams = createSearchParams(router.state.location.search); const enableFilling = Boolean(searchParams.get(SEARCH_PARAM_INFLATE)); - hierarchy && - enableFilling && - fillHistory(hierarchy, dataRouterContext.routeNavigator, routeContext, executor); + if (hierarchy && enableFilling) { + fillHistory(hierarchy, dataRouterContext.routeNavigator, routeContext); + } }, [router]); useLayoutEffect(() => { ContextThrottleService.updateThrottledServiceSettings({ interval, - firstActionDelay: transactionExecutor.initialDelay, - enable: throttled || Boolean(transactionExecutor.initialDelay), + throttled, }); - }, [transactionExecutor.initialDelay, interval, throttled]); + }, [interval, throttled]); const routeNotFound = Boolean( !routeContext.match || diff --git a/src/services/ContextThrottleService.ts b/src/services/ContextThrottleService.ts index 77e5d619..146db69c 100644 --- a/src/services/ContextThrottleService.ts +++ b/src/services/ContextThrottleService.ts @@ -1,23 +1,22 @@ import { EventBus } from './EventBus'; +import { TransactionExecutor } from './TransactionExecutor'; interface ContextThrottleInfo { prevValue: unknown; + updateTimerId: number; throttledValue: unknown; lastUpdateTimestamp: number; - updateTimerId: number; } interface ContextThrottleServiceSettings { interval: number; - firstActionDelay: number; - enable: boolean; + throttled: boolean; } export class ContextThrottleService { private static instance?: ContextThrottleService; - private enable = true; private interval = 0; - private firstActionDelay = 0; + private throttled = true; private contextThrottleMap: Record = {}; // eslint-disable-next-line @typescript-eslint/no-empty-function @@ -51,8 +50,7 @@ export class ContextThrottleService { private getTimeUntilNextUpdate(lastUpdateTimestamp: number) { const timeSinceLastUpdate = Date.now() - lastUpdateTimestamp; const delayUntilNextUpdate = this.interval - timeSinceLastUpdate; - const initialDelay = delayUntilNextUpdate <= 0 ? this.firstActionDelay : 0; - return Math.max(initialDelay, delayUntilNextUpdate); + return delayUntilNextUpdate; } private updateContextValue(contextName: string, newValue: T) { @@ -65,19 +63,25 @@ export class ContextThrottleService { private throttleUpdateContextValue(contextName: string, newValue: T) { const contextData = this.getContextThrottleInfoByName(contextName); + clearTimeout(contextData.updateTimerId); + if (this.isRunSyncActive()) return; + const lastUpdateTimestamp = contextData.lastUpdateTimestamp; const timeUntilNextUpdate = this.getTimeUntilNextUpdate(lastUpdateTimestamp); if (timeUntilNextUpdate <= 0) { this.updateContextValue(contextName, newValue); } else { - clearTimeout(contextData.updateTimerId); contextData.updateTimerId = setTimeout(() => { this.updateContextValue(contextName, newValue); }, timeUntilNextUpdate); } } + private isRunSyncActive() { + return TransactionExecutor.isRunSyncActive; + } + public static triggerContextUpdate(contextName: string, newValue: T) { const throttledService = ContextThrottleService.getInstance(); @@ -85,7 +89,7 @@ export class ContextThrottleService { return; } - if (!throttledService.enable) { + if (!throttledService.throttled && !throttledService.isRunSyncActive()) { throttledService.updateContextValue(contextName, newValue); } else { throttledService.throttleUpdateContextValue(contextName, newValue); @@ -94,8 +98,7 @@ export class ContextThrottleService { public static updateThrottledServiceSettings(settings: ContextThrottleServiceSettings) { const throttledService = ContextThrottleService.getInstance(); - throttledService.enable = settings.enable; throttledService.interval = settings.interval; - throttledService.firstActionDelay = settings.firstActionDelay; + throttledService.throttled = settings.throttled; } } diff --git a/src/services/DefaultRouteNavigator.ts b/src/services/DefaultRouteNavigator.ts index e91307ae..db739f17 100644 --- a/src/services/DefaultRouteNavigator.ts +++ b/src/services/DefaultRouteNavigator.ts @@ -23,7 +23,6 @@ export class DefaultRouteNavigator implements RouteNavigator { constructor( router: Router, private viewHistory: ViewHistory, - private transactionExecutor: TransactionExecutor, setPopout: (popout: JSX.Element | null) => void, ) { this.router = router; @@ -67,13 +66,13 @@ export class DefaultRouteNavigator implements RouteNavigator { if (this.viewHistory.position > 0) { await this.go(-this.viewHistory.position); } else { - await this.transactionExecutor.doNext(); + await TransactionExecutor.doNext(); } } public async go(to: number): Promise { if (to === 0) { - await this.transactionExecutor.doNext(); + await TransactionExecutor.doNext(); } else { await this.router.navigate(to); } @@ -81,8 +80,8 @@ export class DefaultRouteNavigator implements RouteNavigator { public runSync(actions: VoidFunction[]): Promise { const transaction = new NavigationTransaction(actions); - this.transactionExecutor.add(transaction); - this.transactionExecutor.doNext(); + TransactionExecutor.add(transaction); + TransactionExecutor.doNext(); return transaction.donePromise; } @@ -108,7 +107,7 @@ Make sure this route exists or use hideModal with pushPanel set to false.`); } await this.navigate(path, { keepSearchParams: true }); } else { - await this.transactionExecutor.doNext(); + await TransactionExecutor.doNext(); } } } @@ -142,7 +141,7 @@ Make sure this route exists or use hideModal with pushPanel set to false.`); await this.router.navigate(-1); } } else { - await this.transactionExecutor.doNext(); + await TransactionExecutor.doNext(); } } diff --git a/src/services/TransactionExecutor.ts b/src/services/TransactionExecutor.ts index b32c7e74..c334154c 100644 --- a/src/services/TransactionExecutor.ts +++ b/src/services/TransactionExecutor.ts @@ -1,29 +1,49 @@ import { NavigationTransaction } from '../entities/NavigationTransaction'; export class TransactionExecutor { + private static instance?: TransactionExecutor; private transactions: NavigationTransaction[] = []; - constructor(private forceUpdate: () => void) {} + // eslint-disable-next-line @typescript-eslint/no-empty-function + private constructor() {} - get initialDelay(): number { - return this.transactions.length > 1 || - (this.transactions.length > 0 && this.transactions[0].isMultiAction) - ? 100 - : 0; + public static getInstance() { + if (!TransactionExecutor.instance) { + TransactionExecutor.instance = new TransactionExecutor(); + } + + return TransactionExecutor.instance; + } + + public static get isRunSyncActive() { + const transactionExecutor = TransactionExecutor.getInstance(); + const hasMultipleTransactions = transactionExecutor.transactions.length > 1; + const hasSingleMultiActionTransaction = + transactionExecutor.transactions.length === 1 && + transactionExecutor.transactions[0].isMultiAction; + + return hasMultipleTransactions || hasSingleMultiActionTransaction; + } + + public static add(transaction: NavigationTransaction) { + const transactionExecutor = TransactionExecutor.getInstance(); + transactionExecutor.transactions.push(transaction); } - add(transaction: NavigationTransaction): void { - this.transactions.push(transaction); - this.forceUpdate(); + public static resetTransactions() { + const transactionExecutor = TransactionExecutor.getInstance(); + transactionExecutor.transactions = []; } - async doNext(): Promise { + public static async doNext(): Promise { + const transactionExecutor = TransactionExecutor.getInstance(); + const transactions = transactionExecutor.transactions; // Нужно делать асинхронно, иначе будет бесконечный цикл навигация-изменение стейта-навигация... setTimeout(() => { - if (this.transactions.length) { - this.transactions[0].doNext(); - if (this.transactions[0].finished) { - this.transactions.shift(); + if (transactions.length) { + transactions[0].doNext(); + if (transactions[0].finished) { + transactions.shift(); } } }); diff --git a/src/services/index.ts b/src/services/index.ts new file mode 100644 index 00000000..b48a794a --- /dev/null +++ b/src/services/index.ts @@ -0,0 +1,9 @@ +export * from './BridgeService'; +export * from './ContextThrottleService'; +export * from './DefaultRouteNavigator'; +export * from './EventBus'; +export * from './InitialLocation'; +export * from './RouteNavigator.type'; +export * from './TransactionExecutor'; +export * from './ViewHistory'; +export * from './ViewNavigationRecord.type'; diff --git a/src/utils/fillHistory.ts b/src/utils/fillHistory.ts index 8aac0129..b822f91e 100644 --- a/src/utils/fillHistory.ts +++ b/src/utils/fillHistory.ts @@ -25,7 +25,6 @@ export function fillHistory( config: RouteLeaf[], routeNavigator: RouteNavigator, context: RouteContextObject, - transactionExecutor: TransactionExecutor, ) { const leafs = flattenBranch(config, []); const currentLocation = context.state.location; @@ -49,8 +48,8 @@ export function fillHistory( () => routeNavigator.push(to), ]; const transaction = new NavigationTransaction(actions); - transactionExecutor.add(transaction); - transactionExecutor.doNext(); + TransactionExecutor.add(transaction); + TransactionExecutor.doNext(); } }); } diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 00000000..5e8805f1 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,9 @@ +export * from './buildPanelPathFromModalMatch'; +export * from './createBrowserRouter'; +export * from './createHashParamRouter'; +export * from './createHashRouter'; +export * from './createSearchParams'; +export * from './fillHistory'; +export * from './getHrefWithoutHash'; +export * from './react-router-override'; +export * from './utils'; diff --git a/src/utils/react-router-override/index.ts b/src/utils/react-router-override/index.ts new file mode 100644 index 00000000..3efbd081 --- /dev/null +++ b/src/utils/react-router-override/index.ts @@ -0,0 +1,5 @@ +export * from './HashParamHistory'; +export * from './UrlHistoryOptions.type'; +export * from './createLocation'; +export * from './getHistoryState'; +export * from './getUrlBasedHistory'; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 5270c29e..757310c1 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -2,7 +2,6 @@ import { AgnosticRouteMatch, Location, Params, RouterState } from '@remix-run/ro import { RouteContextObject } from '../contexts'; import { PageInternal } from '../type'; import { STATE_KEY_SHOW_MODAL, STATE_KEY_SHOW_POPOUT } from '../const'; -import { useState } from 'react'; export function getParamKeys(path: string | undefined): string[] { return path?.match(/\/:[^\/]+/g)?.map((param) => param.replace('/', '')) ?? []; @@ -54,14 +53,6 @@ export function getDisplayName(WrappedComponent: { displayName?: string; name?: return WrappedComponent.displayName || WrappedComponent.name || 'Component'; } -export function useForceUpdate() { - const [, setState] = useState(0); - - return () => { - setState(Date.now()); - }; -} - export function warning(cond: any, message: string) { if (!cond) { if (typeof console !== 'undefined') console.warn(message);