diff --git a/.changeset/crazy-rats-accept.md b/.changeset/crazy-rats-accept.md new file mode 100644 index 000000000..faf2d7e24 --- /dev/null +++ b/.changeset/crazy-rats-accept.md @@ -0,0 +1,5 @@ +--- +"@stackflow/core": minor +--- + +Expose events used to build a stack via `Stack.events` diff --git a/.changeset/sad-adults-prove.md b/.changeset/sad-adults-prove.md new file mode 100644 index 000000000..830ff1f21 --- /dev/null +++ b/.changeset/sad-adults-prove.md @@ -0,0 +1,5 @@ +--- +"@stackflow/plugin-history-sync": minor +--- + +Expose stack initialization process status for users to disable logging or fetching while initialization transition diff --git a/.pnp.cjs b/.pnp.cjs index 68563498c..bb4b29944 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -6378,6 +6378,7 @@ const RAW_RUNTIME_STATE = ["jest", "virtual:b327d7e228fba669b88a8bb23bcf526374e46fa67e617b1e6848e8a205357fee5ce94b47c49b5a570fd9e8a44fa218a13cd00e2eca327c99114cbd21d72ecf9c#npm:29.7.0"],\ ["react", "npm:18.3.1"],\ ["react-relay", "virtual:991015ceb8acca106af7e64cf676369bf8fb98370003b1af0559fb22931c330c3a09d064107412d6cc26ef286f0afdd26340443bd43177eeda3558644ba5f206#npm:17.0.0"],\ + ["react18-use", "virtual:991015ceb8acca106af7e64cf676369bf8fb98370003b1af0559fb22931c330c3a09d064107412d6cc26ef286f0afdd26340443bd43177eeda3558644ba5f206#npm:0.4.1"],\ ["relay-compiler", "npm:17.0.0"],\ ["relay-runtime", "npm:17.0.0"],\ ["rimraf", "npm:3.0.2"],\ @@ -6419,6 +6420,7 @@ const RAW_RUNTIME_STATE = ["jest", "virtual:b327d7e228fba669b88a8bb23bcf526374e46fa67e617b1e6848e8a205357fee5ce94b47c49b5a570fd9e8a44fa218a13cd00e2eca327c99114cbd21d72ecf9c#npm:29.7.0"],\ ["react", "npm:18.3.1"],\ ["react-relay", "virtual:991015ceb8acca106af7e64cf676369bf8fb98370003b1af0559fb22931c330c3a09d064107412d6cc26ef286f0afdd26340443bd43177eeda3558644ba5f206#npm:17.0.0"],\ + ["react18-use", "virtual:991015ceb8acca106af7e64cf676369bf8fb98370003b1af0559fb22931c330c3a09d064107412d6cc26ef286f0afdd26340443bd43177eeda3558644ba5f206#npm:0.4.1"],\ ["relay-compiler", "npm:17.0.0"],\ ["relay-runtime", "npm:17.0.0"],\ ["rimraf", "npm:3.0.2"],\ @@ -15312,6 +15314,28 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["react18-use", [\ + ["npm:0.4.1", {\ + "packageLocation": "./.yarn/cache/react18-use-npm-0.4.1-3dd4e3b3bc-e8d61ca4ae.zip/node_modules/react18-use/",\ + "packageDependencies": [\ + ["react18-use", "npm:0.4.1"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:991015ceb8acca106af7e64cf676369bf8fb98370003b1af0559fb22931c330c3a09d064107412d6cc26ef286f0afdd26340443bd43177eeda3558644ba5f206#npm:0.4.1", {\ + "packageLocation": "./.yarn/__virtual__/react18-use-virtual-91277bc2f8/0/cache/react18-use-npm-0.4.1-3dd4e3b3bc-e8d61ca4ae.zip/node_modules/react18-use/",\ + "packageDependencies": [\ + ["react18-use", "virtual:991015ceb8acca106af7e64cf676369bf8fb98370003b1af0559fb22931c330c3a09d064107412d6cc26ef286f0afdd26340443bd43177eeda3558644ba5f206#npm:0.4.1"],\ + ["@types/react", "npm:18.3.3"],\ + ["react", "npm:18.3.1"]\ + ],\ + "packagePeers": [\ + "@types/react",\ + "react"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["read-cache", [\ ["npm:1.0.0", {\ "packageLocation": "./.yarn/cache/read-cache-npm-1.0.0-00fa89ed05-83a39149d9.zip/node_modules/read-cache/",\ diff --git a/.yarn/cache/react18-use-npm-0.4.1-3dd4e3b3bc-e8d61ca4ae.zip b/.yarn/cache/react18-use-npm-0.4.1-3dd4e3b3bc-e8d61ca4ae.zip new file mode 100644 index 000000000..7d592e03c Binary files /dev/null and b/.yarn/cache/react18-use-npm-0.4.1-3dd4e3b3bc-e8d61ca4ae.zip differ diff --git a/core/src/Stack.ts b/core/src/Stack.ts index 3dbb5eea1..fe2c0dfc2 100644 --- a/core/src/Stack.ts +++ b/core/src/Stack.ts @@ -53,4 +53,5 @@ export type Stack = { transitionDuration: number; globalTransitionState: "idle" | "loading" | "paused"; pausedEvents?: DomainEvent[]; + events: DomainEvent[]; }; diff --git a/core/src/activity-utils/makeStackReducer.ts b/core/src/activity-utils/makeStackReducer.ts index 2ac710886..5123890cd 100644 --- a/core/src/activity-utils/makeStackReducer.ts +++ b/core/src/activity-utils/makeStackReducer.ts @@ -5,7 +5,7 @@ import type { PausedEvent, ResumedEvent, } from "../event-types"; -import type { Activity, Stack } from "../Stack"; +import type { Stack } from "../Stack"; import { findTargetActivityIndices } from "./findTargetActivityIndices"; import { makeActivitiesReducer } from "./makeActivitiesReducer"; import { makeActivityReducer } from "./makeActivityReducer"; @@ -80,8 +80,8 @@ function withActivitiesReducer( }; } -function noop(stack: Stack) { - return stack; +function noop(stack: Stack, event: DomainEvent) { + return { ...stack, events: [...stack.events, event] }; } export function makeStackReducer(context: { now: number; resumedAt?: number }) { @@ -91,6 +91,7 @@ export function makeStackReducer(context: { now: number; resumedAt?: number }) { return { ...stack, transitionDuration: event.transitionDuration, + events: [...stack.events, event], }; }, context), ), @@ -110,6 +111,7 @@ export function makeStackReducer(context: { now: number; resumedAt?: number }) { : null), }, ], + events: [...stack.events, event], }; }, context, @@ -120,13 +122,14 @@ export function makeStackReducer(context: { now: number; resumedAt?: number }) { return { ...stack, globalTransitionState: "paused", + events: [...stack.events, event], }; }, context), ), Resumed: withActivitiesReducer( (stack: Stack, event: ResumedEvent): Stack => { if (stack.globalTransitionState !== "paused" || !stack.pausedEvents) { - return stack; + return { ...stack, events: [...stack.events, event] }; } const reducer = makeStackReducer({ @@ -135,10 +138,15 @@ export function makeStackReducer(context: { now: number; resumedAt?: number }) { }); const { pausedEvents, ...rest } = stack; - return pausedEvents.reduce(reducer, { + const resumedStack = pausedEvents.reduce(reducer, { ...rest, globalTransitionState: "idle", }); + + return { + ...resumedStack, + events: [...resumedStack.events, event], + }; }, context, ), diff --git a/core/src/aggregate.spec.ts b/core/src/aggregate.spec.ts index d8bbe6b7d..ec5c0e869 100644 --- a/core/src/aggregate.spec.ts +++ b/core/src/aggregate.spec.ts @@ -10,6 +10,8 @@ import type { import type { BaseDomainEvent } from "./event-types/_base"; import { makeEvent } from "./event-utils"; import type { Activity } from "./Stack"; +import { compareBy } from "./utils/compareBy"; +import { uniqBy } from "./utils/uniqBy"; const SECOND = 1000; const MINUTE = 60 * SECOND; @@ -46,20 +48,19 @@ const registeredEvent = ({ const activity = (activity: Activity) => activity; test("aggregate - InitializedEvent만 존재하는 경우, 빈 스택을 내려줍니다", () => { - const output = aggregate( - [ - initializedEvent({ - transitionDuration: 300, - }), - ], - nowTime(), - ); + const events = [ + initializedEvent({ + transitionDuration: 300, + }), + ]; + const output = aggregate(events, nowTime()); expect(output).toStrictEqual({ activities: [], registeredActivities: [], transitionDuration: 300, globalTransitionState: "idle", + events, }); }); @@ -118,6 +119,7 @@ test("aggregate - 푸시하면 스택에 추가됩니다", () => { ], transitionDuration: 300, globalTransitionState: "idle", + events, }); }); @@ -176,6 +178,7 @@ test("aggregate - PushedEvent에 activityId, activityName이 다른 경우 스 ], transitionDuration: 300, globalTransitionState: "idle", + events, }); }); @@ -240,6 +243,7 @@ test("aggregate - 같은 activityId로 여러번 푸시되는 경우 이전의 ], transitionDuration: 300, globalTransitionState: "idle", + events, }); }); @@ -324,6 +328,7 @@ test("aggregate - 다른 activityName으로 두번 푸시하면 스택에 정상 ], transitionDuration: 300, globalTransitionState: "idle", + events, }); }); @@ -408,6 +413,7 @@ test("aggregate - 같은 activityName으로 두번 푸시하면 정상적으로 ], transitionDuration: 300, globalTransitionState: "idle", + events, }); }); @@ -462,6 +468,7 @@ test("aggregate - 푸시한 직후에는 transition.state가 enter-active 입니 ], transitionDuration: 300, globalTransitionState: "loading", + events, }); }); @@ -516,6 +523,7 @@ test("aggregate - 현재 시간과 변화된 시간의 차가 InitializedEvent ], transitionDuration: 300, globalTransitionState: "loading", + events, }); }); @@ -570,6 +578,7 @@ test("aggregate - 푸시한 이후 InitializedEvent에서 셋팅된 transitionDu ], transitionDuration: 300, globalTransitionState: "idle", + events, }); }); @@ -650,6 +659,7 @@ test("aggregate - 여러번 푸시한 경우, transitionDuration 전에 푸시 ], transitionDuration: 300, globalTransitionState: "loading", + events, }); }); @@ -734,6 +744,7 @@ test("aggregate - Pop하면 최상단에 존재하는 Activity가 exit-done 상 ], transitionDuration: 300, globalTransitionState: "idle", + events, }); }); @@ -772,16 +783,14 @@ test("aggregate - Pop을 여러번하면 차례대로 exit-done 상태가 됩니 eventDate: enoughPastTime(), })), ]; + const o1Events = [ + ...initEvents, + (poppedEvent1 = makeEvent("Popped", { + eventDate: enoughPastTime(), + })), + ]; - const o1 = aggregate( - [ - ...initEvents, - (poppedEvent1 = makeEvent("Popped", { - eventDate: enoughPastTime(), - })), - ], - nowTime(), - ); + const o1 = aggregate(o1Events, nowTime()); expect(o1).toStrictEqual({ activities: [ @@ -851,20 +860,19 @@ test("aggregate - Pop을 여러번하면 차례대로 exit-done 상태가 됩니 ], transitionDuration: 300, globalTransitionState: "idle", + events: o1Events, }); - const o2 = aggregate( - [ - ...initEvents, - (poppedEvent2 = makeEvent("Popped", { - eventDate: enoughPastTime(), - })), - (poppedEvent3 = makeEvent("Popped", { - eventDate: enoughPastTime(), - })), - ], - nowTime(), - ); + const o2Events = [ + ...initEvents, + (poppedEvent2 = makeEvent("Popped", { + eventDate: enoughPastTime(), + })), + (poppedEvent3 = makeEvent("Popped", { + eventDate: enoughPastTime(), + })), + ]; + const o2 = aggregate(o2Events, nowTime()); expect(o2).toStrictEqual({ activities: [ @@ -935,6 +943,7 @@ test("aggregate - Pop을 여러번하면 차례대로 exit-done 상태가 됩니 ], transitionDuration: 300, globalTransitionState: "idle", + events: o2Events, }); }); @@ -965,16 +974,14 @@ test("aggregate - 가장 바닥에 있는 Activity는 Pop 되지 않습니다", eventDate: enoughPastTime(), })), ]; + const output1Events = [ + ...initEvents, + (poppedEvent1 = makeEvent("Popped", { + eventDate: enoughPastTime(), + })), + ]; - const output1 = aggregate( - [ - ...initEvents, - (poppedEvent1 = makeEvent("Popped", { - eventDate: enoughPastTime(), - })), - ], - nowTime(), - ); + const output1 = aggregate(output1Events, nowTime()); expect(output1).toStrictEqual({ activities: [ @@ -1025,20 +1032,20 @@ test("aggregate - 가장 바닥에 있는 Activity는 Pop 되지 않습니다", ], transitionDuration: 300, globalTransitionState: "idle", + events: output1Events, }); - const output2 = aggregate( - [ - ...initEvents, - (poppedEvent2 = makeEvent("Popped", { - eventDate: enoughPastTime(), - })), - makeEvent("Popped", { - eventDate: enoughPastTime(), - }), - ], - nowTime(), - ); + const output2Events = [ + ...initEvents, + (poppedEvent2 = makeEvent("Popped", { + eventDate: enoughPastTime(), + })), + makeEvent("Popped", { + eventDate: enoughPastTime(), + }), + ]; + + const output2 = aggregate(output2Events, nowTime()); expect(output2).toStrictEqual({ activities: [ @@ -1089,6 +1096,7 @@ test("aggregate - 가장 바닥에 있는 Activity는 Pop 되지 않습니다", ], transitionDuration: 300, globalTransitionState: "idle", + events: output2Events, }); }); @@ -1113,22 +1121,20 @@ test("aggregate - push 후 replace 한 뒤 pop 을 수행하면 pop을 무효화 eventDate: enoughPastTime(), })), ]; + const events = [ + ...initEvents, + (replacedEvent1 = makeEvent("Replaced", { + activityId: "a2", + activityName: "sample", + activityParams: {}, + eventDate: enoughPastTime(), + })), + makeEvent("Popped", { + eventDate: enoughPastTime(), + }), + ]; - const output1 = aggregate( - [ - ...initEvents, - (replacedEvent1 = makeEvent("Replaced", { - activityId: "a2", - activityName: "sample", - activityParams: {}, - eventDate: enoughPastTime(), - })), - makeEvent("Popped", { - eventDate: enoughPastTime(), - }), - ], - nowTime(), - ); + const output1 = aggregate(events, nowTime()); expect(output1).toStrictEqual({ activities: [ @@ -1182,6 +1188,7 @@ test("aggregate - push 후 replace 한 뒤 pop 을 수행하면 pop을 무효화 ], transitionDuration: 300, globalTransitionState: "idle", + events, }); }); @@ -1268,6 +1275,7 @@ test("aggregate - transitionDuration 이전에 Pop을 한 경우 exit-active 상 ], transitionDuration: 300, globalTransitionState: "loading", + events, }); }); @@ -1293,8 +1301,9 @@ test("aggregate - 이벤트가 중복되거나 순서가 섞여도 정상적으 const e5 = makeEvent("Popped", { eventDate: enoughPastTime(), }); + const events = [e5, e1, e4, e3, e5, e1, e1, e2]; - const output = aggregate([e5, e1, e4, e3, e5, e1, e1, e2], nowTime()); + const output = aggregate(events, nowTime()); expect(output).toStrictEqual({ activities: [ @@ -1345,6 +1354,7 @@ test("aggregate - 이벤트가 중복되거나 순서가 섞여도 정상적으 ], transitionDuration: 300, globalTransitionState: "idle", + events: [e1, e2, e3, e4, e5], }); }); @@ -1497,6 +1507,7 @@ test("aggregate - 같은 activity.id로 푸시되는 경우, 기존에 푸시되 ], transitionDuration: 300, globalTransitionState: "loading", + events, }); }); @@ -1557,6 +1568,7 @@ test("aggregate - PushedEvent에 params가 담겨있는 경우 액티비티에 ], transitionDuration: 300, globalTransitionState: "loading", + events, }); }); @@ -1649,6 +1661,7 @@ test("aggregate - ReplacedEvent가 발생한 직후 최상단의 Activity를 유 ], transitionDuration: 300, globalTransitionState: "loading", + events, }); }); @@ -1742,6 +1755,7 @@ test("aggregate - ReplacedEvent가 발생한 후 transitionDuration만큼 지난 ], transitionDuration: 300, globalTransitionState: "idle", + events, }); }); @@ -1868,6 +1882,7 @@ test("aggregate - ReplacedEvent가 두 번 발생한 후 transitionDuration만 ], transitionDuration: 300, globalTransitionState: "idle", + events, }); }); @@ -1929,6 +1944,7 @@ test("aggregate - skipEnterActiveState가 true이면 eventDate가 transitionDura ], transitionDuration: 300, globalTransitionState: "idle", + events, }); }); @@ -2015,6 +2031,7 @@ test("aggregate - skipExitActiveState가 true이면 eventDate가 transitionDurat ], transitionDuration: 300, globalTransitionState: "idle", + events, }); }); @@ -2109,6 +2126,7 @@ test("aggregate - skipExitActiveState가 true이면 ReplacedEvent가 발생한 ], transitionDuration: 300, globalTransitionState: "idle", + events, }); }); @@ -2169,6 +2187,7 @@ test("aggregate - PushedEvent에 activityContext가 담겨있는 경우 액티 ], transitionDuration: 300, globalTransitionState: "loading", + events, }); }); @@ -2261,6 +2280,7 @@ test("aggregate - ReplacedEvent에 activityContext가 담겨있는 경우 액티 ], transitionDuration: 300, globalTransitionState: "loading", + events, }); }); @@ -2361,6 +2381,7 @@ test("aggregate - ReplacedEvent에 현재 상단에 존재하는 activityId가 ], transitionDuration: 300, globalTransitionState: "idle", + events, }); }); @@ -2493,6 +2514,7 @@ test("aggregate - ReplacedEvent에 현재 중간에 존재하는 activityId가 ], transitionDuration: 300, globalTransitionState: "idle", + events, }); }); @@ -2625,6 +2647,7 @@ test("aggregate - ReplacedEvent에 현재 중간에 존재하는 activityId가 ], transitionDuration: 300, globalTransitionState: "idle", + events, }); }); @@ -2826,6 +2849,10 @@ test("aggregate - ReplacedEvent가 같은 activityId로 여러번 수행되었 ], transitionDuration: 350, globalTransitionState: "loading", + events: uniqBy( + [...events].sort((a, b) => a.eventDate - b.eventDate), + (e) => e.id, + ), }); }); @@ -2927,6 +2954,7 @@ test("aggregate - 현재 특정 액티비티가 애니메이션 되고 있는 ], transitionDuration: 300, globalTransitionState: "loading", + events, }); }); @@ -3027,6 +3055,7 @@ test("aggregate - 현재 특정 액티비티가 애니메이션이 되고 있는 ], transitionDuration: 300, globalTransitionState: "idle", + events, }); }); @@ -3104,6 +3133,7 @@ test("aggregate - StepPushedEvent가 발생하면, 최상단 액티비티의 파 ], transitionDuration: 300, globalTransitionState: "idle", + events, }); }); @@ -3174,6 +3204,7 @@ test("aggregate - StepPushedEvent가 쌓인 상태에서, StepPoppedEvent가 들 ], transitionDuration: 300, globalTransitionState: "idle", + events, }); }); @@ -3278,6 +3309,7 @@ test("aggregate - StepPushedEvent가 쌓인 상태에서, PoppedEvent가 들어 ], transitionDuration: 300, globalTransitionState: "idle", + events, }); }); @@ -3392,6 +3424,7 @@ test("aggregate - StepPushedEvent가 쌓인 상태에서, PoppedEvent가 들어 ], transitionDuration: 300, globalTransitionState: "loading", + events, }); }); @@ -3461,6 +3494,7 @@ test("aggregate - StepReplacedEvent가 발생하면, 최상단 액티비티의 ], transitionDuration: 300, globalTransitionState: "idle", + events, }); }); @@ -3533,6 +3567,7 @@ test("aggregate - 만약 StepPoppedEvent를 통해 제거할 수 있는 영역 ], transitionDuration: 300, globalTransitionState: "idle", + events, }); }); @@ -3577,6 +3612,7 @@ test("aggregate - RegisteredActivityEvent에 paramsSchema가 있다면 registere ], transitionDuration: 300, globalTransitionState: "idle", + events, }); }); @@ -3686,6 +3722,7 @@ test("aggregate - After Push > Replace > Replace (skipped), first pushed activit ], transitionDuration: 300, globalTransitionState: "idle", + events, }); }); @@ -3820,6 +3857,7 @@ test("aggregate - After Push > Push > Replace > Replace, first pushed activity s ], transitionDuration: 300, globalTransitionState: "idle", + events, }); }); @@ -3932,6 +3970,7 @@ test("aggregate - After Push > Push > Pop > Replace, first pushed activity shoul ], transitionDuration: 300, globalTransitionState: "idle", + events, }); }); @@ -4019,6 +4058,7 @@ test("aggregate - StepPushedEvent must be ignored when top activity is not targe ], transitionDuration: 300, globalTransitionState: "idle", + events, }); }); @@ -4026,7 +4066,7 @@ test("aggregate - Pause되면 이벤트가 반영되지 않고, globalTransition let pushedEvent1: PushedEvent; let pushedEvent2: PushedEvent; - const events = [ + const eventsUntilPausedEvent = [ initializedEvent({ transitionDuration: 300, }), @@ -4043,6 +4083,9 @@ test("aggregate - Pause되면 이벤트가 반영되지 않고, globalTransition activityParams: {}, })), makeEvent("Paused", {}), + ]; + const events = [ + ...eventsUntilPausedEvent, (pushedEvent2 = makeEvent("Pushed", { activityId: "activity-2", activityName: "b", @@ -4085,6 +4128,7 @@ test("aggregate - Pause되면 이벤트가 반영되지 않고, globalTransition transitionDuration: 300, globalTransitionState: "paused", pausedEvents: [pushedEvent2], + events: eventsUntilPausedEvent, }); }); @@ -4175,6 +4219,7 @@ test("aggregate - Resumed 되면 해당 시간 이후로 Transition이 정상작 ], transitionDuration: 300, globalTransitionState: "loading", + events, }); }); @@ -4285,5 +4330,6 @@ test("aggregate - StepPushedEvent에 hasZIndex 필드가 true이면, Step에 zIn ], transitionDuration: 300, globalTransitionState: "idle", + events, }); }); diff --git a/core/src/aggregate.ts b/core/src/aggregate.ts index 2e1dd09aa..d8db38d98 100644 --- a/core/src/aggregate.ts +++ b/core/src/aggregate.ts @@ -9,7 +9,7 @@ export function aggregate(inputEvents: DomainEvent[], now: number): Stack { * 1. Pre-process */ const events = uniqBy( - [...inputEvents].sort((a, b) => compareBy(a, b, (e) => e.id)), + [...inputEvents].sort((a, b) => a.eventDate - b.eventDate), (e) => e.id, ); @@ -27,6 +27,7 @@ export function aggregate(inputEvents: DomainEvent[], now: number): Stack { globalTransitionState: "idle", registeredActivities: [], transitionDuration: 0, + events: [], }); /** diff --git a/core/src/produceEffects.spec.ts b/core/src/produceEffects.spec.ts index 3600e5bf7..aee890996 100644 --- a/core/src/produceEffects.spec.ts +++ b/core/src/produceEffects.spec.ts @@ -32,6 +32,7 @@ test("productEffects - 알 수 없는 이유로 두 object가 다르다면, %SOM registeredActivities: [{ name: "hello" }], transitionDuration: 300, globalTransitionState: "idle", + events: [], }, { activities: [ @@ -64,6 +65,7 @@ test("productEffects - 알 수 없는 이유로 두 object가 다르다면, %SOM registeredActivities: [{ name: "hello" }], transitionDuration: 300, globalTransitionState: "loading", + events: [], }, ), ).toEqual([ @@ -81,6 +83,7 @@ test("productEffects - 새로운 액티비티가 추가되었다면, PUSHED 이 registeredActivities: [{ name: "hello" }], transitionDuration: 300, globalTransitionState: "idle", + events: [], }, { activities: [ @@ -111,6 +114,7 @@ test("productEffects - 새로운 액티비티가 추가되었다면, PUSHED 이 registeredActivities: [{ name: "hello" }], transitionDuration: 300, globalTransitionState: "loading", + events: [], }, ), ).toEqual([ @@ -154,6 +158,7 @@ test("productEffects - 여러개 액티비티가 추가되었다면, PUSHED 이 registeredActivities: [{ name: "hello" }], transitionDuration: 300, globalTransitionState: "idle", + events: [], }, { activities: [ @@ -207,6 +212,7 @@ test("productEffects - 여러개 액티비티가 추가되었다면, PUSHED 이 registeredActivities: [{ name: "hello" }], transitionDuration: 300, globalTransitionState: "loading", + events: [], }, ), ).toEqual([ @@ -323,6 +329,7 @@ test("productEffects - 액티비티 상태가 exit-active로 변한 액티비티 registeredActivities: [{ name: "hello" }], transitionDuration: 300, globalTransitionState: "idle", + events: [], }, { activities: [ @@ -376,6 +383,7 @@ test("productEffects - 액티비티 상태가 exit-active로 변한 액티비티 registeredActivities: [{ name: "hello" }], transitionDuration: 300, globalTransitionState: "loading", + events: [], }, ), ).toEqual([ @@ -466,6 +474,7 @@ test("productEffects - 액티비티 상태가 exit-active로 변한 액티비티 registeredActivities: [{ name: "hello" }], transitionDuration: 300, globalTransitionState: "idle", + events: [], }, { activities: [ @@ -519,6 +528,7 @@ test("productEffects - 액티비티 상태가 exit-active로 변한 액티비티 registeredActivities: [{ name: "hello" }], transitionDuration: 300, globalTransitionState: "loading", + events: [], }, ), ).toEqual([ @@ -635,6 +645,7 @@ test("productEffects - PushedEvent로 인해 액티비티 상태가 enter-active registeredActivities: [{ name: "hello" }], transitionDuration: 300, globalTransitionState: "idle", + events: [], }, { activities: [ @@ -688,6 +699,7 @@ test("productEffects - PushedEvent로 인해 액티비티 상태가 enter-active registeredActivities: [{ name: "hello" }], transitionDuration: 300, globalTransitionState: "loading", + events: [], }, ), ).toEqual([ @@ -778,6 +790,7 @@ test("productEffects - Replaced 이벤트로 인해 액티비티 상태가 enter registeredActivities: [{ name: "hello" }], transitionDuration: 300, globalTransitionState: "idle", + events: [], }, { activities: [ @@ -854,6 +867,7 @@ test("productEffects - Replaced 이벤트로 인해 액티비티 상태가 enter registeredActivities: [{ name: "hello" }], transitionDuration: 300, globalTransitionState: "loading", + events: [], }, ), ).toEqual([ @@ -944,6 +958,7 @@ test("productEffects - Replaced 이벤트로 인해 아래 액티비티 상태 registeredActivities: [{ name: "hello" }], transitionDuration: 300, globalTransitionState: "loading", + events: [], }, { activities: [ @@ -997,6 +1012,7 @@ test("productEffects - Replaced 이벤트로 인해 아래 액티비티 상태 registeredActivities: [{ name: "hello" }], transitionDuration: 300, globalTransitionState: "idle", + events: [], }, ), ).toEqual([ @@ -1061,6 +1077,7 @@ test("productEffects - 아래 액티비티가 Replaced를 통해 Push된 상태 registeredActivities: [{ name: "hello" }], transitionDuration: 300, globalTransitionState: "loading", + events: [], }, { activities: [ @@ -1114,6 +1131,7 @@ test("productEffects - 아래 액티비티가 Replaced를 통해 Push된 상태 registeredActivities: [{ name: "hello" }], transitionDuration: 300, globalTransitionState: "idle", + events: [], }, ), ).toEqual([ @@ -1178,6 +1196,7 @@ test("productEffects - Replaced 이벤트에 같은 activityId를 넘겨주어 registeredActivities: [{ name: "hello" }], transitionDuration: 300, globalTransitionState: "idle", + events: [], }, { activities: [ @@ -1231,6 +1250,7 @@ test("productEffects - Replaced 이벤트에 같은 activityId를 넘겨주어 registeredActivities: [{ name: "hello" }], transitionDuration: 300, globalTransitionState: "loading", + events: [], }, ), ).toEqual([ @@ -1298,6 +1318,7 @@ test("productEffects - StepPushed가 작동해 steps가 늘어난 경우, STEP_P registeredActivities: [{ name: "hello" }], transitionDuration: 300, globalTransitionState: "idle", + events: [], }, { activities: [ @@ -1344,6 +1365,7 @@ test("productEffects - StepPushed가 작동해 steps가 늘어난 경우, STEP_P registeredActivities: [{ name: "hello" }], transitionDuration: 300, globalTransitionState: "idle", + events: [], }, ), ).toEqual([ @@ -1570,6 +1592,7 @@ test("produceEffects - StepPushed after Replaced events produces only STEP_PUSHE registeredActivities: [], transitionDuration: 350, globalTransitionState: "idle", + events: [], }, { activities: [ @@ -1701,6 +1724,7 @@ test("produceEffects - StepPushed after Replaced events produces only STEP_PUSHE registeredActivities: [], transitionDuration: 350, globalTransitionState: "idle", + events: [], }, ), ).toEqual([ @@ -1833,6 +1857,7 @@ test("productEffects - StepReplaced가 작동해 파라미터가 바뀐 경우, registeredActivities: [{ name: "hello" }], transitionDuration: 300, globalTransitionState: "idle", + events: [], }, { activities: [ @@ -1867,6 +1892,7 @@ test("productEffects - StepReplaced가 작동해 파라미터가 바뀐 경우, registeredActivities: [{ name: "hello" }], transitionDuration: 300, globalTransitionState: "idle", + events: [], }, ), ).toEqual([ @@ -1964,6 +1990,7 @@ test("productEffects - Popped가 작동해 steps가 모두 삭제되면, POPPED registeredActivities: [{ name: "hello" }], transitionDuration: 300, globalTransitionState: "idle", + events: [], }, { activities: [ @@ -1994,6 +2021,7 @@ test("productEffects - Popped가 작동해 steps가 모두 삭제되면, POPPED registeredActivities: [{ name: "hello" }], transitionDuration: 300, globalTransitionState: "loading", + events: [], }, ), ).toEqual([ @@ -2089,12 +2117,14 @@ test("produceEffects - Paused가 작동해, globalTransitionState가 paused로 globalTransitionState: "idle", registeredActivities: [], transitionDuration: 270, + events: [], }, { activities: [], globalTransitionState: "paused", registeredActivities: [], transitionDuration: 270, + events: [], }, ), ).toEqual([ @@ -2113,12 +2143,14 @@ test("produceEffects - Paused가 작동해, globalTransitionState가 paused로 globalTransitionState: "loading", registeredActivities: [], transitionDuration: 270, + events: [], }, { activities: [], globalTransitionState: "paused", registeredActivities: [], transitionDuration: 270, + events: [], }, ), ).toEqual([ @@ -2139,12 +2171,14 @@ test("produceEffects - Resumed가 작동해, globalTransitionState가 paused에 globalTransitionState: "paused", registeredActivities: [], transitionDuration: 270, + events: [], }, { activities: [], globalTransitionState: "idle", registeredActivities: [], transitionDuration: 270, + events: [], }, ), ).toEqual([ @@ -2163,12 +2197,14 @@ test("produceEffects - Resumed가 작동해, globalTransitionState가 paused에 globalTransitionState: "paused", registeredActivities: [], transitionDuration: 270, + events: [], }, { activities: [], globalTransitionState: "loading", registeredActivities: [], transitionDuration: 270, + events: [], }, ), ).toEqual([ diff --git a/extensions/plugin-history-sync/package.json b/extensions/plugin-history-sync/package.json index 9a3d3bab8..86ceeb74c 100644 --- a/extensions/plugin-history-sync/package.json +++ b/extensions/plugin-history-sync/package.json @@ -43,6 +43,7 @@ "dependencies": { "flatted": "^3.3.1", "history": "^5.3.0", + "react18-use": "^0.4.1", "url-pattern": "^1.0.3" }, "devDependencies": { diff --git a/extensions/plugin-history-sync/src/ActivityActivationCountsContext.ts b/extensions/plugin-history-sync/src/ActivityActivationCountsContext.ts new file mode 100644 index 000000000..c34dc927d --- /dev/null +++ b/extensions/plugin-history-sync/src/ActivityActivationCountsContext.ts @@ -0,0 +1,5 @@ +import { createContext } from "react"; + +export const ActivityActivationCountsContext = createContext< + { activityId: string; activationCount: number }[] +>([]); diff --git a/extensions/plugin-history-sync/src/ActivityActivationMonitor/ActivityActivationMonitor.ts b/extensions/plugin-history-sync/src/ActivityActivationMonitor/ActivityActivationMonitor.ts new file mode 100644 index 000000000..802ed037d --- /dev/null +++ b/extensions/plugin-history-sync/src/ActivityActivationMonitor/ActivityActivationMonitor.ts @@ -0,0 +1,7 @@ +import type { Stack } from "@stackflow/core"; + +export interface ActivityActivationMonitor { + captureStackChange(stack: Stack): void; + getActivationCount(): number; + getTargetId(): string; +} diff --git a/extensions/plugin-history-sync/src/ActivityActivationMonitor/CountPublishingActivityActivationMonitor.ts b/extensions/plugin-history-sync/src/ActivityActivationMonitor/CountPublishingActivityActivationMonitor.ts new file mode 100644 index 000000000..26f030294 --- /dev/null +++ b/extensions/plugin-history-sync/src/ActivityActivationMonitor/CountPublishingActivityActivationMonitor.ts @@ -0,0 +1,40 @@ +import type { Stack } from "@stackflow/core"; +import type { Publisher } from "../Publisher"; +import type { ActivityActivationMonitor } from "./ActivityActivationMonitor"; + +export class CountPublishingActivityActivationMonitor + implements ActivityActivationMonitor +{ + private activityActivationMonitor: ActivityActivationMonitor; + private publisher: Publisher; + + constructor( + activityActivationMonitor: ActivityActivationMonitor, + publisher: Publisher, + ) { + this.activityActivationMonitor = activityActivationMonitor; + this.publisher = publisher; + } + + captureStackChange(stack: Stack): void { + const previousActivationCount = + this.activityActivationMonitor.getActivationCount(); + + this.activityActivationMonitor.captureStackChange(stack); + + const currentActivationCount = + this.activityActivationMonitor.getActivationCount(); + + if (currentActivationCount !== previousActivationCount) { + this.publisher.publish(currentActivationCount); + } + } + + getActivationCount(): number { + return this.activityActivationMonitor.getActivationCount(); + } + + getTargetId(): string { + return this.activityActivationMonitor.getTargetId(); + } +} diff --git a/extensions/plugin-history-sync/src/ActivityActivationMonitor/DefaultHistoryActivityActivationMonitor.ts b/extensions/plugin-history-sync/src/ActivityActivationMonitor/DefaultHistoryActivityActivationMonitor.ts new file mode 100644 index 000000000..eb40255ef --- /dev/null +++ b/extensions/plugin-history-sync/src/ActivityActivationMonitor/DefaultHistoryActivityActivationMonitor.ts @@ -0,0 +1,61 @@ +import type { Stack } from "@stackflow/core"; +import { + type ActivityNavigationEvent, + isActivityNavigationEvent, +} from "../NavigationEvent"; +import { + isTerminated, + type NavigationProcess, +} from "../NavigationProcess/NavigationProcess"; +import type { ActivityActivationMonitor } from "./ActivityActivationMonitor"; + +export class DefaultHistoryActivityActivationMonitor + implements ActivityActivationMonitor +{ + private targetId: string; + private initialSetupProcess: NavigationProcess; + private focusCount: number; + private previousActivationTrigger: ActivityNavigationEvent | null; + + constructor(targetId: string, initialSetupProcess: NavigationProcess) { + this.targetId = targetId; + this.initialSetupProcess = initialSetupProcess; + this.focusCount = 0; + this.previousActivationTrigger = null; + } + + captureStackChange(stack: Stack): void { + const navigationProcessStatus = this.initialSetupProcess.getStatus(); + + if (!isTerminated(navigationProcessStatus)) return; + + const targetActivity = stack.activities.find( + (activity) => activity.id === this.targetId, + ); + + if (!targetActivity || !targetActivity.isActive) return; + + const latestActivityNavigation = stack.events.findLast( + isActivityNavigationEvent, + ); + + if ( + !latestActivityNavigation || + (this.previousActivationTrigger && + latestActivityNavigation.eventDate <= + this.previousActivationTrigger.eventDate) + ) + return; + + this.focusCount++; + this.previousActivationTrigger = latestActivityNavigation; + } + + getActivationCount(): number { + return this.focusCount; + } + + getTargetId(): string { + return this.targetId; + } +} diff --git a/extensions/plugin-history-sync/src/Mutex.ts b/extensions/plugin-history-sync/src/Mutex.ts new file mode 100644 index 000000000..ab4637c54 --- /dev/null +++ b/extensions/plugin-history-sync/src/Mutex.ts @@ -0,0 +1,24 @@ +export class Mutex { + private latestlyBookedSession: Promise = Promise.resolve(); + + acquire(): Promise<{ release: () => void }> { + return new Promise((resolveSessionHandle) => { + this.latestlyBookedSession = this.latestlyBookedSession.then( + () => + new Promise((resolveSession) => + resolveSessionHandle({ release: () => resolveSession() }), + ), + ); + }); + } + + async runExclusively(thunk: () => Promise): Promise> { + const { release } = await this.acquire(); + + try { + return await thunk(); + } finally { + release(); + } + } +} diff --git a/extensions/plugin-history-sync/src/NavigationEvent.ts b/extensions/plugin-history-sync/src/NavigationEvent.ts new file mode 100644 index 000000000..ab5abbea9 --- /dev/null +++ b/extensions/plugin-history-sync/src/NavigationEvent.ts @@ -0,0 +1,44 @@ +import type { + DomainEvent, + PoppedEvent, + PushedEvent, + ReplacedEvent, + StepPoppedEvent, + StepPushedEvent, + StepReplacedEvent, +} from "@stackflow/core"; + +export type ActivityNavigationEvent = PushedEvent | PoppedEvent | ReplacedEvent; + +export type StepNavigationEvent = + | StepPushedEvent + | StepPoppedEvent + | StepReplacedEvent; + +export type NavigationEvent = ActivityNavigationEvent | StepNavigationEvent; + +export function isActivityNavigationEvent( + event: DomainEvent, +): event is ActivityNavigationEvent { + return ( + event.name === "Pushed" || + event.name === "Popped" || + event.name === "Replaced" + ); +} + +export function isStepNavigationEvent( + event: DomainEvent, +): event is StepNavigationEvent { + return ( + event.name === "StepPushed" || + event.name === "StepPopped" || + event.name === "StepReplaced" + ); +} + +export function isNavigationEvent( + event: DomainEvent, +): event is NavigationEvent { + return isActivityNavigationEvent(event) || isStepNavigationEvent(event); +} diff --git a/extensions/plugin-history-sync/src/NavigationProcess/NavigationProcess.ts b/extensions/plugin-history-sync/src/NavigationProcess/NavigationProcess.ts new file mode 100644 index 000000000..27d84857c --- /dev/null +++ b/extensions/plugin-history-sync/src/NavigationProcess/NavigationProcess.ts @@ -0,0 +1,26 @@ +import type { PushedEvent, Stack, StepPushedEvent } from "@stackflow/core"; + +export interface NavigationProcess { + captureNavigationOpportunity( + stack: Stack | null, + ): (Omit | Omit)[]; + + getStatus(): NavigationProcessStatus; +} + +export const NavigationProcessStatus = { + IDLE: "idle", + PROGRESS: "progress", + SUCCEEDED: "succeeded", + FAILED: "failed", +} as const; + +export type NavigationProcessStatus = + (typeof NavigationProcessStatus)[keyof typeof NavigationProcessStatus]; + +export function isTerminated(status: NavigationProcessStatus): boolean { + return ( + status === NavigationProcessStatus.SUCCEEDED || + status === NavigationProcessStatus.FAILED + ); +} diff --git a/extensions/plugin-history-sync/src/NavigationProcess/SerialNavigationProcess.ts b/extensions/plugin-history-sync/src/NavigationProcess/SerialNavigationProcess.ts new file mode 100644 index 000000000..9dc4c6022 --- /dev/null +++ b/extensions/plugin-history-sync/src/NavigationProcess/SerialNavigationProcess.ts @@ -0,0 +1,105 @@ +import type { PushedEvent, Stack, StepPushedEvent } from "@stackflow/core"; +import { isNavigationEvent, type NavigationEvent } from "../NavigationEvent"; +import { + isTerminated, + type NavigationProcess, + NavigationProcessStatus, +} from "./NavigationProcess"; + +export class SerialNavigationProcess implements NavigationProcess { + private status: NavigationProcessStatus; + private pendingNavigations: (() => ( + | Omit + | Omit + )[])[]; + private dispatchedEvents: ( + | Omit + | Omit + )[]; + private baseNavigationEvents: NavigationEvent[]; + + constructor( + navigations: (() => ( + | Omit + | Omit + )[])[], + baseNavigationEvents: NavigationEvent[] = [], + ) { + this.status = + navigations.length > 0 + ? NavigationProcessStatus.IDLE + : NavigationProcessStatus.SUCCEEDED; + this.pendingNavigations = navigations.slice(); + this.dispatchedEvents = []; + this.baseNavigationEvents = baseNavigationEvents; + } + + captureNavigationOpportunity( + stack: Stack | null, + ): (Omit | Omit)[] { + if (isTerminated(this.status)) return []; + if (stack && stack.globalTransitionState !== "idle") return []; + + if (this.pendingNavigations.length === 0) { + this.status = NavigationProcessStatus.SUCCEEDED; + + return []; + } + + const navigationHistory = stack + ? stack.events.filter(isNavigationEvent) + : []; + + if ( + !( + this.verifyAllDispatchedEventsAreInNavigationHistory( + navigationHistory, + ) && this.verifyNoUnknownNavigationEvents(navigationHistory) + ) + ) { + this.pendingNavigations = []; + this.status = NavigationProcessStatus.FAILED; + + return []; + } + + const nextNavigation = this.pendingNavigations.shift(); + + if (!nextNavigation) return []; + + const nextNavigationEvents = nextNavigation(); + + this.dispatchedEvents.push(...nextNavigationEvents); + this.status = NavigationProcessStatus.PROGRESS; + + return nextNavigationEvents; + } + + getStatus(): NavigationProcessStatus { + return this.status; + } + + private verifyAllDispatchedEventsAreInNavigationHistory( + navigationHistory: NavigationEvent[], + ): boolean { + const navigationHistoryEventIds = new Set( + navigationHistory.map((e) => e.id), + ); + + return this.dispatchedEvents.every((event) => + navigationHistoryEventIds.has(event.id), + ); + } + + private verifyNoUnknownNavigationEvents( + navigationHistory: NavigationEvent[], + ): boolean { + const knownNavigationEvents = new Set( + [...this.baseNavigationEvents, ...this.dispatchedEvents].map((e) => e.id), + ); + + return navigationHistory.every((event) => + knownNavigationEvents.has(event.id), + ); + } +} diff --git a/extensions/plugin-history-sync/src/Publisher.ts b/extensions/plugin-history-sync/src/Publisher.ts new file mode 100644 index 000000000..d81ad27e0 --- /dev/null +++ b/extensions/plugin-history-sync/src/Publisher.ts @@ -0,0 +1,24 @@ +import { Mutex } from "./Mutex"; + +export class Publisher { + private subscribers: ((value: T) => Promise)[] = []; + private publishLock: Mutex = new Mutex(); + + subscribe(subscriber: (value: T) => Promise): () => void { + this.subscribers.push(subscriber); + + return () => { + this.subscribers = this.subscribers.filter((s) => s !== subscriber); + }; + } + + publish(value: T): Promise[]> { + const targetSubscribers = this.subscribers.slice(); + + return this.publishLock.runExclusively(() => + Promise.allSettled( + targetSubscribers.map((subscriber) => subscriber(value)), + ), + ); + } +} diff --git a/extensions/plugin-history-sync/src/historySyncPlugin.tsx b/extensions/plugin-history-sync/src/historySyncPlugin.tsx index 646314bca..2082dabb1 100644 --- a/extensions/plugin-history-sync/src/historySyncPlugin.tsx +++ b/extensions/plugin-history-sync/src/historySyncPlugin.tsx @@ -3,19 +3,31 @@ import type { Config, RegisteredActivityName, } from "@stackflow/config"; -import { id, makeEvent, type StackflowActions } from "@stackflow/core"; +import { + id, + type PushedEvent, + type Stack, + type StepPushedEvent, +} from "@stackflow/core"; import type { StackflowReactPlugin } from "@stackflow/react"; import type { ActivityComponentType } from "@stackflow/react/future"; import type { History, Listener } from "history"; import { createBrowserHistory, createMemoryHistory } from "history"; +import { useSyncExternalStore } from "react"; import UrlPattern from "url-pattern"; +import { ActivityActivationCountsContext } from "./ActivityActivationCountsContext"; +import type { ActivityActivationMonitor } from "./ActivityActivationMonitor/ActivityActivationMonitor"; +import { DefaultHistoryActivityActivationMonitor } from "./ActivityActivationMonitor/DefaultHistoryActivityActivationMonitor"; import { HistoryQueueProvider } from "./HistoryQueueContext"; import { parseState, pushState, replaceState } from "./historyState"; import { last } from "./last"; import { makeHistoryTaskQueue } from "./makeHistoryTaskQueue"; import type { UrlPatternOptions } from "./makeTemplate"; import { makeTemplate, pathToUrl, urlSearchParamsToMap } from "./makeTemplate"; +import type { NavigationProcess } from "./NavigationProcess/NavigationProcess"; +import { SerialNavigationProcess } from "./NavigationProcess/SerialNavigationProcess"; import { normalizeActivityRouteMap } from "./normalizeActivityRouteMap"; +import { Publisher } from "./Publisher"; import type { RouteLike } from "./RouteLike"; import { RoutesProvider } from "./RoutesContext"; import { sortActivityRoutes } from "./sortActivityRoutes"; @@ -88,43 +100,91 @@ export function historySyncPlugin< return () => { let pushFlag = 0; let silentFlag = false; - let pendingDefaultHistoryEntryInsertionTasks: - | ((actions: StackflowActions) => void)[] - | null = null; - const defaultHistoryEntryEntities: Set = new Set(); + let initialSetupProcess: NavigationProcess | null = null; + const activityActivationMonitors: ActivityActivationMonitor[] = []; + const activityActivationCountsChangeNotifier = new Publisher(); const { requestHistoryTick } = makeHistoryTaskQueue(history); - function defaultHistorySetupCheckpoint(actions: StackflowActions) { - if (!pendingDefaultHistoryEntryInsertionTasks) return; + const subscribeActivityActivationCountsChange = ( + subscriber: () => void, + ) => { + return activityActivationCountsChangeNotifier.subscribe(async () => + subscriber(), + ); + }; + + let cachedActivityActivationCounts: + | { activityId: string; activationCount: number }[] + | null = null; + const getActivityActivationCounts = () => { + const currentActivityActivationCounts = activityActivationMonitors.map( + (activityActivationMonitor) => ({ + activityId: activityActivationMonitor.getTargetId(), + activationCount: activityActivationMonitor.getActivationCount(), + }), + ); + + if ( + !cachedActivityActivationCounts || + cachedActivityActivationCounts.length !== + currentActivityActivationCounts.length || + cachedActivityActivationCounts.some( + ({ + activityId: cachedActivityId, + activationCount: cachedActivationCount, + }) => + currentActivityActivationCounts.some( + ({ activityId, activationCount }) => + activityId === cachedActivityId && + activationCount !== cachedActivationCount, + ), + ) + ) { + cachedActivityActivationCounts = currentActivityActivationCounts; + } - const stack = actions.getStack(); + return cachedActivityActivationCounts; + }; - if (stack.globalTransitionState !== "idle") return; + const runActivityActivationMonitors = (stack: Stack) => { + let changeOccurred = false; - const nextTask = pendingDefaultHistoryEntryInsertionTasks.shift(); + for (const activityActivationMonitor of activityActivationMonitors) { + const previousActivationCount = + activityActivationMonitor.getActivationCount(); - if (pendingDefaultHistoryEntryInsertionTasks.length === 0) { - pendingDefaultHistoryEntryInsertionTasks = null; - } + activityActivationMonitor.captureStackChange(stack); - if (nextTask) { - nextTask(actions); + if ( + previousActivationCount !== + activityActivationMonitor.getActivationCount() + ) { + changeOccurred = true; + } } - } - function clearPendingDefaultHistoryEntryInsertionTasks() { - pendingDefaultHistoryEntryInsertionTasks = null; - defaultHistoryEntryEntities.clear(); - } + if (changeOccurred) { + activityActivationCountsChangeNotifier.publish(); + } + }; return { key: "plugin-history-sync", wrapStack({ stack }) { + const activityActivationCounts = useSyncExternalStore( + subscribeActivityActivationCountsChange, + getActivityActivationCounts, + ); + return ( - {stack.render()} + + {stack.render()} + ); @@ -197,144 +257,89 @@ export function historySyncPlugin< const defaultHistory = targetActivityRoute.defaultHistory?.(params) ?? []; - if (defaultHistory[0]) { - const initialHistoryEntry = defaultHistory[0]; - const enoughPastTime = new Date().getTime() - MINUTE; - - pendingDefaultHistoryEntryInsertionTasks = [ - ...(initialHistoryEntry.additionalSteps?.length - ? [ - (actions: StackflowActions) => { - for (const { - stepParams, - hasZIndex, - } of initialHistoryEntry.additionalSteps!) { - const stepId = id(); - - defaultHistoryEntryEntities.add(stepId); - - actions.stepPush({ - stepId, - stepParams, - hasZIndex, - }); - } - }, - ] - : []), - ...defaultHistory - .slice(1) - .map( - ({ activityName, activityParams, additionalSteps }) => - ({ push, stepPush }: StackflowActions) => { - const activityId = id(); - - defaultHistoryEntryEntities.add(activityId); - - push({ - activityId, - activityName, - activityParams, - activityContext: { - path: currentPath, - lazyActivityComponentRenderContext: { - shouldRenderImmediately: true, - }, + initialSetupProcess = new SerialNavigationProcess([ + ...defaultHistory.map( + ({ activityName, activityParams, additionalSteps = [] }) => + () => { + const events: ( + | Omit + | Omit + )[] = [ + { + name: "Pushed", + id: id(), + activityId: id(), + activityName, + activityParams: { + ...activityParams, + }, + activityContext: { + path: currentPath, + lazyActivityComponentRenderContext: { + shouldRenderImmediately: true, }, - }); - - for (const { stepParams, hasZIndex } of additionalSteps ?? - []) { - const stepId = id(); - - defaultHistoryEntryEntities.add(stepId); - - stepPush({ - stepId, - stepParams, - hasZIndex, - }); - } + }, }, - ), - ({ push }) => { - const template = makeTemplate( - targetActivityRoute, - options.urlPatternOptions, - ); - const activityParams = - template.parse(currentPath) ?? - urlSearchParamsToMap(pathToUrl(currentPath).searchParams); - const activityId = id(); - - defaultHistoryEntryEntities.add(activityId); - - push({ - activityId, - activityName: targetActivityRoute.activityName, - activityParams: { - ...activityParams, - }, - activityContext: { - path: currentPath, - lazyActivityComponentRenderContext: { - shouldRenderImmediately: true, - }, - }, - }); - }, - ]; - - const initialActivityId = id(); - - defaultHistoryEntryEntities.add(initialActivityId); - - return [ - makeEvent("Pushed", { - activityId: initialActivityId, - activityName: initialHistoryEntry.activityName, - activityParams: { - ...initialHistoryEntry.activityParams, + ...additionalSteps.map( + ({ + stepParams, + hasZIndex, + }): Omit => ({ + name: "StepPushed", + id: id(), + stepId: id(), + stepParams, + hasZIndex, + }), + ), + ]; + + for (const event of events) { + if (event.name === "Pushed") { + activityActivationMonitors.push( + new DefaultHistoryActivityActivationMonitor( + event.activityId, + initialSetupProcess!, + ), + ); + } + } + + return events; }, - eventDate: enoughPastTime, + ), + () => [ + { + name: "Pushed", + id: id(), + activityId: id(), + activityName: targetActivityRoute.activityName, + activityParams: + makeTemplate( + targetActivityRoute, + options.urlPatternOptions, + ).parse(currentPath) ?? + urlSearchParamsToMap(pathToUrl(currentPath).searchParams), activityContext: { path: currentPath, lazyActivityComponentRenderContext: { shouldRenderImmediately: true, }, }, - }), - ]; - } - - const template = makeTemplate( - targetActivityRoute, - options.urlPatternOptions, - ); - const activityParams = - template.parse(currentPath) ?? - urlSearchParamsToMap(pathToUrl(currentPath).searchParams); - - return [ - makeEvent("Pushed", { - activityId: id(), - activityName: targetActivityRoute.activityName, - activityParams: { - ...activityParams, }, - eventDate: new Date().getTime() - MINUTE, - activityContext: { - path: currentPath, - lazyActivityComponentRenderContext: { - shouldRenderImmediately: true, - }, - }, - }), - ]; + ], + ]); + + return initialSetupProcess + .captureNavigationOpportunity(null) + .map((event) => ({ + ...event, + eventDate: Date.now() - MINUTE, + })); }, - onInit({ actions }) { - const { getStack, dispatchEvent, push, stepPush } = actions; - const rootActivity = getStack().activities[0]; + onInit({ actions: { getStack, dispatchEvent, push, stepPush } }) { + const stack = getStack(); + const rootActivity = stack.activities[0]; const match = activityRoutes.find( (r) => r.activityName === rootActivity.name, @@ -485,22 +490,15 @@ export function historySyncPlugin< history.listen(onPopState); - const activities = getStack().activities; + initialSetupProcess + ?.captureNavigationOpportunity(stack) + .forEach((event) => + event.name === "Pushed" ? push(event) : stepPush(event), + ); - if (activities.every(({ id }) => defaultHistoryEntryEntities.has(id))) { - defaultHistorySetupCheckpoint(actions); - } else { - clearPendingDefaultHistoryEntryInsertionTasks(); - } + runActivityActivationMonitors(stack); }, onPushed({ effect: { activity } }) { - if ( - pendingDefaultHistoryEntryInsertionTasks && - !defaultHistoryEntryEntities.has(activity.id) - ) { - clearPendingDefaultHistoryEntryInsertionTasks(); - } - if (pushFlag) { pushFlag -= 1; return; @@ -525,13 +523,6 @@ export function historySyncPlugin< }); }, onStepPushed({ effect: { activity, step } }) { - if ( - pendingDefaultHistoryEntryInsertionTasks && - !defaultHistoryEntryEntities.has(step.id) - ) { - clearPendingDefaultHistoryEntryInsertionTasks(); - } - if (pushFlag) { pushFlag -= 1; return; @@ -557,10 +548,6 @@ export function historySyncPlugin< }); }, onReplaced({ effect: { activity } }) { - if (pendingDefaultHistoryEntryInsertionTasks) { - clearPendingDefaultHistoryEntryInsertionTasks(); - } - if (!activity.isActive) { return; } @@ -584,10 +571,6 @@ export function historySyncPlugin< }); }, onStepReplaced({ effect: { activity, step } }) { - if (pendingDefaultHistoryEntryInsertionTasks) { - clearPendingDefaultHistoryEntryInsertionTasks(); - } - if (!activity.isActive) { return; } @@ -611,16 +594,6 @@ export function historySyncPlugin< }); }); }, - onPopped() { - if (pendingDefaultHistoryEntryInsertionTasks) { - clearPendingDefaultHistoryEntryInsertionTasks(); - } - }, - onStepPopped() { - if (pendingDefaultHistoryEntryInsertionTasks) { - clearPendingDefaultHistoryEntryInsertionTasks(); - } - }, onBeforePush({ actionParams, actions: { overrideActionParams } }) { if ( !actionParams.activityContext || @@ -734,8 +707,16 @@ export function historySyncPlugin< } } }, - onChanged({ actions }) { - defaultHistorySetupCheckpoint(actions); + onChanged({ actions: { getStack, push, stepPush } }) { + const stack = getStack(); + + initialSetupProcess + ?.captureNavigationOpportunity(stack) + .forEach((event) => + event.name === "Pushed" ? push(event) : stepPush(event), + ); + + runActivityActivationMonitors(stack); }, }; }; diff --git a/extensions/plugin-history-sync/src/index.ts b/extensions/plugin-history-sync/src/index.ts index 15d322f6e..76604b2ed 100644 --- a/extensions/plugin-history-sync/src/index.ts +++ b/extensions/plugin-history-sync/src/index.ts @@ -3,3 +3,4 @@ export * from "./historySyncPlugin"; export { makeTemplate, UrlPatternOptions } from "./makeTemplate"; export { Route, RouteLike } from "./RouteLike"; export { useRoutes } from "./RoutesContext"; +export { useIsActivatedActivity } from "./useIsActivatedActivity"; diff --git a/extensions/plugin-history-sync/src/useIsActivatedActivity.ts b/extensions/plugin-history-sync/src/useIsActivatedActivity.ts new file mode 100644 index 000000000..081cb7818 --- /dev/null +++ b/extensions/plugin-history-sync/src/useIsActivatedActivity.ts @@ -0,0 +1,13 @@ +import { useActivity } from "@stackflow/react"; +import { useContext } from "react"; +import { ActivityActivationCountsContext } from "./ActivityActivationCountsContext"; + +export function useIsActivatedActivity() { + const { id } = useActivity(); + const activityActivationCounts = useContext(ActivityActivationCountsContext); + const activityActivationCount = activityActivationCounts.find( + (activityActivationCount) => activityActivationCount.activityId === id, + )?.activationCount; + + return activityActivationCount === undefined || activityActivationCount > 0; +} diff --git a/yarn.lock b/yarn.lock index 012fbfd14..222dabf65 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5370,6 +5370,7 @@ __metadata: jest: "npm:^29.7.0" react: "npm:^18.3.1" react-relay: "npm:^17.0.0" + react18-use: "npm:^0.4.1" relay-compiler: "npm:^17.0.0" relay-runtime: "npm:^17.0.0" rimraf: "npm:^3.0.2" @@ -13080,6 +13081,15 @@ __metadata: languageName: node linkType: hard +"react18-use@npm:^0.4.1": + version: 0.4.1 + resolution: "react18-use@npm:0.4.1" + peerDependencies: + react: ">=18.0.0" + checksum: 10/e8d61ca4ae7ab1b1b206221a470aa17a7fd58deaf1d5e97d9733030bdc20b01d1dfda9c5172b13c6f0602cf50f5832643e4ecefcf67730e3de97b3e72aff7cc2 + languageName: node + linkType: hard + "react@npm:^18.3.1": version: 18.3.1 resolution: "react@npm:18.3.1"