diff --git a/integrations/react/src/future/defineActivity.ts b/integrations/react/src/future/defineActivity.ts new file mode 100644 index 000000000..802823ec4 --- /dev/null +++ b/integrations/react/src/future/defineActivity.ts @@ -0,0 +1,79 @@ +import type { ActivityComponentType as InternalActivityComponentType } from "../__internal__/ActivityComponentType"; + +export type ActivityRoute = + | string + | { + path: string; + decode?: (params: Record) => unknown; + defaultHistory?: () => Array<{ + activityName: string; + activityParams: Record; + }>; + }; + +export type ActivityLoader = (args: { + params: TParams; + config: { + activities: Array<{ name: string; route?: ActivityRoute }>; + transitionDuration: number; + }; +}) => unknown; + +export interface ActivityDefinitionInput> { + component: InternalActivityComponentType; + route?: ActivityRoute; + loader?: ActivityLoader; + transition?: "slide" | "modal" | "bottomSheet" | "fade"; +} + +export interface ActivityDefinitionOutput< + TName extends string, + TParams extends Record = Record, +> { + name: TName; + component: InternalActivityComponentType; + route?: ActivityRoute; + loader?: ActivityLoader; + transition?: "slide" | "modal" | "bottomSheet" | "fade"; + __params?: TParams; +} + +export function defineActivity(name: TName) { + return = Record>( + input: ActivityDefinitionInput, + ): ActivityDefinitionOutput => ({ + name, + ...input, + }); +} + +export type DestinationsMap = { + [name: string]: ActivityDefinitionOutput | NavigationDefinitionOutput; +}; + +export interface NavigationDefinitionOutput< + TName extends string, + TActivities extends DestinationsMap = DestinationsMap, +> { + __type: "navigation"; + name: TName; + activities: TActivities; + initialActivity: Extract; +} + +export function defineNavigation(name: TName) { + return (input: { + activities: TActivities; + initialActivity: Extract; + }): NavigationDefinitionOutput => ({ + __type: "navigation", + name, + ...input, + }); +} + +export function isNavigationDefinition( + def: ActivityDefinitionOutput | NavigationDefinitionOutput, +): def is NavigationDefinitionOutput { + return "__type" in def && def.__type === "navigation"; +} diff --git a/integrations/react/src/future/index.ts b/integrations/react/src/future/index.ts index 2cd088cc0..128843030 100644 --- a/integrations/react/src/future/index.ts +++ b/integrations/react/src/future/index.ts @@ -16,3 +16,5 @@ export * from "./useConfig"; export * from "./useFlow"; export * from "./usePrepare"; export * from "./useStepFlow"; +export * from "./defineActivity"; +export * from "./structuredStackflow"; diff --git a/integrations/react/src/future/structuredStackflow.tsx b/integrations/react/src/future/structuredStackflow.tsx new file mode 100644 index 000000000..2c94a25cf --- /dev/null +++ b/integrations/react/src/future/structuredStackflow.tsx @@ -0,0 +1,440 @@ +import { + type CoreStore, + makeCoreStore, + makeEvent, + type PushedEvent, +} from "@stackflow/core"; +import React, { useMemo } from "react"; +import isEqual from "react-fast-compare"; +import { ActivityComponentMapProvider } from "../__internal__/ActivityComponentMapProvider"; +import { makeActivityId } from "../__internal__/activity"; +import { CoreProvider } from "../__internal__/core"; +import MainRenderer from "../__internal__/MainRenderer"; +import { PluginsProvider } from "../__internal__/plugins"; +import { isBrowser, makeRef } from "../__internal__/utils"; +import type { StackflowReactPlugin } from "../stable"; +import { ConfigProvider } from "./ConfigProvider"; +import { + type ActivityDefinitionOutput, + type DestinationsMap, + isNavigationDefinition, + type NavigationDefinitionOutput, +} from "./defineActivity"; +import { DataLoaderProvider, loaderPlugin } from "./loader"; +import type { StackComponentType } from "./StackComponentType"; + +const DEFAULT_LOADER_CACHE_MAX_AGE = 1000 * 30; + +export type ActivitiesMap = DestinationsMap; + +type AllActivityNames = T extends DestinationsMap + ? { + [K in keyof T]: T[K] extends NavigationDefinitionOutput + ? AllActivityNames + : T[K] extends ActivityDefinitionOutput + ? N + : never; + }[keyof T] + : never; + +type GetActivityDefinition = { + [N in keyof T]: T[N] extends ActivityDefinitionOutput + ? ActivityDefinitionOutput + : T[N] extends NavigationDefinitionOutput + ? GetActivityDefinition + : never; +}[keyof T]; + +export type InferActivityParamsFromMap< + TActivities extends DestinationsMap, + TName extends string, +> = GetActivityDefinition extends ActivityDefinitionOutput< + string, + infer P +> + ? P + : never; + +export type TypedActions = { + push, string>>( + activityName: K, + activityParams: InferActivityParamsFromMap, + options?: { animate?: boolean }, + ): { activityId: string }; + + replace, string>>( + activityName: K, + activityParams: InferActivityParamsFromMap, + options?: { animate?: boolean; activityId?: string }, + ): { activityId: string }; + + pop(): void; + pop(options: { animate?: boolean }): void; + pop(count: number, options?: { animate?: boolean }): void; + + canGoBack: boolean; +}; + +export type TypedStepActions> = { + stepPush(params: TParams): void; + stepReplace(params: TParams): void; + stepPop(): void; +}; + +export interface StructuredStackflowInput { + activities: TActivities; + transitionDuration?: number; + initialActivity: Extract, string>; + plugins?: Array; +} + +function flattenActivities( + activities: DestinationsMap, + result: Array<{ + name: string; + route?: any; + loader?: any; + component: any; + }> = [], + visitedNames: Set = new Set(), +): Array<{ name: string; route?: any; loader?: any; component: any }> { + for (const [key, def] of Object.entries(activities)) { + if (isNavigationDefinition(def)) { + flattenActivities(def.activities, result, visitedNames); + } else { + if (key !== def.name) { + throw new Error( + `Activity map key "${key}" does not match activity name "${def.name}". ` + + `The map key must match the name provided to defineActivity(). ` + + `Change the key to "${def.name}" or update defineActivity("${def.name}") to defineActivity("${key}").`, + ); + } + if (visitedNames.has(def.name)) { + throw new Error( + `Duplicate activity name detected: "${def.name}". Activity names must be unique across the entire application.`, + ); + } + visitedNames.add(def.name); + result.push({ + ...def, + name: def.name, + route: def.route, + loader: def.loader, + component: def.component, + }); + } + } + + return result; +} + +export function createRoutesFromActivities( + activities: DestinationsMap, + routes: Record = {}, +): Record { + for (const [, def] of Object.entries(activities)) { + if (isNavigationDefinition(def)) { + createRoutesFromActivities(def.activities, routes); + } else { + if (def.route) { + routes[def.name] = def.route; + } + } + } + return routes; +} + +export interface StructuredStackflowOutput { + Stack: StackComponentType; + actions: TypedActions; + stepActions: TypedStepActions>; + useFlow: () => TypedActions; + useStepFlow: () => TypedStepActions>; +} + +export function structuredStackflow( + input: StructuredStackflowInput, +): StructuredStackflowOutput { + const transitionDuration = input.transitionDuration ?? 270; + + const flattenedActivities = flattenActivities(input.activities); + + const activitiesConfig = flattenedActivities.map( + ({ component, ...activity }) => ({ + ...activity, + name: activity.name, + route: activity.route, + loader: activity.loader, + }), + ); + + const componentsMap = Object.fromEntries( + flattenedActivities.map((activity) => [activity.name, activity.component]), + ); + + const loaderDataCacheMap = new Map< + string, + { params: Record; data: unknown }[] + >(); + + const loadData = ( + activityName: string, + activityParams: Record, + ) => { + const cache = loaderDataCacheMap.get(activityName); + const cacheEntry = cache?.find((entry) => + isEqual(entry.params, activityParams), + ); + + if (cacheEntry) { + return cacheEntry.data; + } + + const activityConfig = activitiesConfig.find( + (activity) => activity.name === activityName, + ); + + if (!activityConfig) { + throw new Error(`Activity ${activityName} is not registered.`); + } + + const loaderData = activityConfig.loader?.({ + params: activityParams, + config: { + activities: activitiesConfig, + transitionDuration, + }, + }); + + const newCacheEntry = { + params: activityParams, + data: loaderData, + }; + + if (cache) { + cache.push(newCacheEntry); + } else { + loaderDataCacheMap.set(activityName, [newCacheEntry]); + } + + const clearCache = () => { + const cache = loaderDataCacheMap.get(activityName); + if (!cache) return; + loaderDataCacheMap.set( + activityName, + cache.filter((entry) => entry !== newCacheEntry), + ); + }; + + const clearCacheAfterMaxAge = () => { + setTimeout( + clearCache, + (activityConfig.loader as any)?.loaderCacheMaxAge ?? + DEFAULT_LOADER_CACHE_MAX_AGE, + ); + }; + + Promise.resolve(loaderData).then(clearCacheAfterMaxAge, (error) => { + clearCache(); + throw error; + }); + + return loaderData; + }; + + const internalConfig = { + activities: activitiesConfig, + transitionDuration, + initialActivity: () => input.initialActivity, + decorate: () => {}, + }; + + const plugins = [ + ...(input.plugins ?? []) + .flat(Number.POSITIVE_INFINITY as 0) + .map((p) => p as StackflowReactPlugin), + loaderPlugin( + { + config: internalConfig as any, + components: componentsMap as any, + }, + loadData, + ), + ]; + + const enoughPastTime = () => Date.now() - transitionDuration * 2; + + const staticCoreStore = makeCoreStore({ + initialEvents: [ + makeEvent("Initialized", { + transitionDuration, + eventDate: enoughPastTime(), + }), + ...activitiesConfig.map((activity) => + makeEvent("ActivityRegistered", { + activityName: activity.name, + eventDate: enoughPastTime(), + }), + ), + ], + plugins: [], + }); + + const [getCoreStore, setCoreStore] = makeRef(); + + const createActions = ( + getStore: () => CoreStore | undefined, + ): TypedActions => ({ + push(activityName, activityParams, options) { + const activityId = makeActivityId(); + getStore()?.actions.push({ + activityId, + activityName: activityName as string, + activityParams: activityParams as any, + skipEnterActiveState: options?.animate === false, + }); + return { activityId }; + }, + replace(activityName, activityParams, options) { + const activityId = options?.activityId ?? makeActivityId(); + getStore()?.actions.replace({ + activityId, + activityName: activityName as string, + activityParams: activityParams as any, + skipEnterActiveState: options?.animate === false, + }); + return { activityId }; + }, + pop( + countOrOptions?: number | { animate?: boolean }, + options?: { animate?: boolean }, + ) { + let count = 1; + let opts: { animate?: boolean } = {}; + + if (typeof countOrOptions === "object") { + opts = countOrOptions; + } else if (typeof countOrOptions === "number") { + count = countOrOptions; + opts = options ?? {}; + } + + for (let i = 0; i < count; i++) { + getStore()?.actions.pop({ + skipExitActiveState: i === 0 ? opts.animate === false : true, + }); + } + }, + get canGoBack() { + const stack = getStore()?.actions.getStack(); + if (!stack) return false; + const activeActivities = stack.activities.filter( + (a) => + a.transitionState === "enter-done" || + a.transitionState === "enter-active", + ); + return activeActivities.length > 1; + }, + }); + + const createStepActions = ( + getStore: () => CoreStore | undefined, + ): TypedStepActions> => ({ + stepPush(params) { + getStore()?.actions.stepPush({ + stepId: makeActivityId(), + stepParams: params as any, + }); + }, + stepReplace(params) { + getStore()?.actions.stepReplace({ + stepId: makeActivityId(), + stepParams: params as any, + }); + }, + stepPop() { + getStore()?.actions.stepPop({}); + }, + }); + + const Stack: StackComponentType = React.memo((props) => { + const initialContext = useMemo( + () => ({ + ...props.initialContext, + ...(props.initialLoaderData + ? { initialLoaderData: props.initialLoaderData } + : null), + }), + [], + ); + + const coreStore = useMemo(() => { + const prevCoreStore = getCoreStore(); + + if (isBrowser() && prevCoreStore) { + return prevCoreStore; + } + + const initialPushedEventsByOption = [ + makeEvent("Pushed", { + activityId: makeActivityId(), + activityName: input.initialActivity, + activityParams: {}, + eventDate: enoughPastTime(), + skipEnterActiveState: false, + }), + ]; + + const store = makeCoreStore({ + initialEvents: [ + ...staticCoreStore.pullEvents(), + ...initialPushedEventsByOption, + ], + initialContext, + plugins, + handlers: { + onInitialActivityIgnored: (initialPushedEvents) => { + if (isBrowser()) { + console.warn( + `Stackflow - Some plugin overrides an "initialActivity" option. The "initialActivity" option you set to "${ + (initialPushedEvents[0] as PushedEvent).activityName + }" is ignored.`, + ); + } + }, + }, + }); + + if (isBrowser()) { + store.init(); + setCoreStore(store); + } + + return store; + }, []); + + return ( + + + + + + + + + + + + ); + }); + + Stack.displayName = "Stack"; + + return { + Stack, + actions: createActions(() => getCoreStore() ?? undefined), + stepActions: createStepActions(() => getCoreStore() ?? undefined), + useFlow: () => createActions(() => getCoreStore() ?? undefined), + useStepFlow: () => createStepActions(() => getCoreStore() ?? undefined), + }; +}