From 6c4ebbbff7c9ae83920d78da5c2d0c5c5686cf3b Mon Sep 17 00:00:00 2001 From: "JH.Lee" Date: Thu, 30 Apr 2026 18:18:11 +0900 Subject: [PATCH 1/3] fix(plugin-history-sync): only call fallbackActivity when no route matches Move the fallbackActivity callback invocation back inside the no-match branch so it is no longer called on every initialization. This restores the pre-1.8.0 contract where the callback is only invoked when currentPath does not match any registered route. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../fix-fallback-activity-called-on-match.md | 5 ++ .../src/historySyncPlugin.spec.ts | 48 +++++++++++++++++++ .../src/historySyncPlugin.tsx | 23 +++++---- 3 files changed, 64 insertions(+), 12 deletions(-) create mode 100644 .changeset/fix-fallback-activity-called-on-match.md diff --git a/.changeset/fix-fallback-activity-called-on-match.md b/.changeset/fix-fallback-activity-called-on-match.md new file mode 100644 index 000000000..bd2c4e06b --- /dev/null +++ b/.changeset/fix-fallback-activity-called-on-match.md @@ -0,0 +1,5 @@ +--- +"@stackflow/plugin-history-sync": patch +--- + +Fix `fallbackActivity` callback being invoked on every initialization regardless of route matching outcome. Restored the pre-1.8.0 contract: the callback is now called only when no route matches `currentPath`. Apps that perform side effects in this callback (e.g. Sentry logging for unknown deep links) no longer fire on successful matches. diff --git a/extensions/plugin-history-sync/src/historySyncPlugin.spec.ts b/extensions/plugin-history-sync/src/historySyncPlugin.spec.ts index 5ba28d372..7299b24ed 100644 --- a/extensions/plugin-history-sync/src/historySyncPlugin.spec.ts +++ b/extensions/plugin-history-sync/src/historySyncPlugin.spec.ts @@ -170,6 +170,54 @@ describe("historySyncPlugin", () => { expect(activeActivity(actions.getStack())?.params.title).toEqual("hello"); }); + test("historySyncPlugin - 초기에 매칭하는 라우트가 있으면 fallbackActivity 콜백을 호출하지 않습니다", async () => { + history = createMemoryHistory({ + initialEntries: ["/articles/123"], + }); + + const fallbackActivity = jest.fn(() => "Home"); + + stackflow({ + activityNames: ["Home", "Article"], + plugins: [ + historySyncPlugin({ + history, + routes: { + Home: "/home", + Article: "/articles/:articleId", + }, + fallbackActivity, + }), + ], + }); + + expect(fallbackActivity).not.toHaveBeenCalled(); + }); + + test("historySyncPlugin - 초기에 매칭하는 라우트가 없으면 fallbackActivity 콜백을 호출합니다", async () => { + history = createMemoryHistory({ + initialEntries: ["/non-existent-path"], + }); + + const fallbackActivity = jest.fn(() => "Home"); + + stackflow({ + activityNames: ["Home", "Article"], + plugins: [ + historySyncPlugin({ + history, + routes: { + Home: "/home", + Article: "/articles/:articleId", + }, + fallbackActivity, + }), + ], + }); + + expect(fallbackActivity).toHaveBeenCalled(); + }); + test("historySyncPlugin - actions.push() 후에, URL 상태가 알맞게 바뀝니다", async () => { await actions.push({ activityId: "a1", diff --git a/extensions/plugin-history-sync/src/historySyncPlugin.tsx b/extensions/plugin-history-sync/src/historySyncPlugin.tsx index 2428d0407..65362f85e 100644 --- a/extensions/plugin-history-sync/src/historySyncPlugin.tsx +++ b/extensions/plugin-history-sync/src/historySyncPlugin.tsx @@ -228,22 +228,21 @@ export function historySyncPlugin< } const currentPath = resolveCurrentPath(); - const fallbackActivityName = options.fallbackActivity({ - initialContext, + const matchedActivityRoute = activityRoutes.find((activityRoute) => { + const template = makeTemplate( + activityRoute, + options.urlPatternOptions, + ); + const activityParams = template.parse(currentPath); + + return activityParams !== null; }); const targetActivityRoute = - activityRoutes.find((activityRoute) => { - const template = makeTemplate( - activityRoute, - options.urlPatternOptions, - ); - const activityParams = template.parse(currentPath); - - return activityParams !== null; - }) ?? + matchedActivityRoute ?? activityRoutes.find( (activityRoute) => - activityRoute.activityName === fallbackActivityName, + activityRoute.activityName === + options.fallbackActivity({ initialContext }), )!; const pattern = new UrlPattern( `${targetActivityRoute.path}(/)`, From 59e721cac5055a5b367c243f6228b74889e532e4 Mon Sep 17 00:00:00 2001 From: "JH.Lee" Date: Thu, 30 Apr 2026 18:27:49 +0900 Subject: [PATCH 2/3] test(plugin-history-sync): narrow jest.fn return type for fallbackActivity The K type parameter on historySyncPlugin is constrained to a string literal union of activity names. Using jest.fn(() => "Home") infers Mock, widening the return to string and failing the assignability check. Annotate the return type so the literal narrows. Co-Authored-By: Claude Opus 4.7 (1M context) --- extensions/plugin-history-sync/src/historySyncPlugin.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/plugin-history-sync/src/historySyncPlugin.spec.ts b/extensions/plugin-history-sync/src/historySyncPlugin.spec.ts index 7299b24ed..9d698f702 100644 --- a/extensions/plugin-history-sync/src/historySyncPlugin.spec.ts +++ b/extensions/plugin-history-sync/src/historySyncPlugin.spec.ts @@ -175,7 +175,7 @@ describe("historySyncPlugin", () => { initialEntries: ["/articles/123"], }); - const fallbackActivity = jest.fn(() => "Home"); + const fallbackActivity = jest.fn((): "Home" => "Home"); stackflow({ activityNames: ["Home", "Article"], @@ -199,7 +199,7 @@ describe("historySyncPlugin", () => { initialEntries: ["/non-existent-path"], }); - const fallbackActivity = jest.fn(() => "Home"); + const fallbackActivity = jest.fn((): "Home" => "Home"); stackflow({ activityNames: ["Home", "Article"], From eb2ef69c6878f4e6d69750dc61f2667f3e3ff286 Mon Sep 17 00:00:00 2001 From: "JH.Lee" Date: Thu, 30 Apr 2026 18:35:40 +0900 Subject: [PATCH 3/3] fix(plugin-history-sync): cache fallbackActivity result outside find predicate Calling options.fallbackActivity inside the activityRoutes.find predicate caused it to fire once per route iteration. Hoist the call into an IIFE so it runs at most once per overrideInitialEvents invocation when no route matches. Tightened the regression test to assert exactly one invocation per plugin instance instead of the looser "any call" assertion. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/historySyncPlugin.spec.ts | 29 ++++++++++--------- .../src/historySyncPlugin.tsx | 15 ++++++---- 2 files changed, 25 insertions(+), 19 deletions(-) diff --git a/extensions/plugin-history-sync/src/historySyncPlugin.spec.ts b/extensions/plugin-history-sync/src/historySyncPlugin.spec.ts index 9d698f702..66a1f6b03 100644 --- a/extensions/plugin-history-sync/src/historySyncPlugin.spec.ts +++ b/extensions/plugin-history-sync/src/historySyncPlugin.spec.ts @@ -194,28 +194,29 @@ describe("historySyncPlugin", () => { expect(fallbackActivity).not.toHaveBeenCalled(); }); - test("historySyncPlugin - 초기에 매칭하는 라우트가 없으면 fallbackActivity 콜백을 호출합니다", async () => { + test("historySyncPlugin - 초기에 매칭하는 라우트가 없으면 fallbackActivity 콜백을 plugin instance당 한 번만 호출합니다", async () => { history = createMemoryHistory({ initialEntries: ["/non-existent-path"], }); const fallbackActivity = jest.fn((): "Home" => "Home"); - stackflow({ - activityNames: ["Home", "Article"], - plugins: [ - historySyncPlugin({ - history, - routes: { - Home: "/home", - Article: "/articles/:articleId", - }, - fallbackActivity, - }), - ], + const plugin = historySyncPlugin({ + history, + routes: { + Home: "/home", + Article: "/articles/:articleId", + }, + fallbackActivity, + }); + + const pluginInstance = plugin(); + pluginInstance.overrideInitialEvents?.({ + initialEvents: [], + initialContext: {}, }); - expect(fallbackActivity).toHaveBeenCalled(); + expect(fallbackActivity).toHaveBeenCalledTimes(1); }); test("historySyncPlugin - actions.push() 후에, URL 상태가 알맞게 바뀝니다", async () => { diff --git a/extensions/plugin-history-sync/src/historySyncPlugin.tsx b/extensions/plugin-history-sync/src/historySyncPlugin.tsx index 65362f85e..02164e725 100644 --- a/extensions/plugin-history-sync/src/historySyncPlugin.tsx +++ b/extensions/plugin-history-sync/src/historySyncPlugin.tsx @@ -237,13 +237,18 @@ export function historySyncPlugin< return activityParams !== null; }); - const targetActivityRoute = - matchedActivityRoute ?? - activityRoutes.find( + const targetActivityRoute = (() => { + if (matchedActivityRoute) { + return matchedActivityRoute; + } + const fallbackActivityName = options.fallbackActivity({ + initialContext, + }); + return activityRoutes.find( (activityRoute) => - activityRoute.activityName === - options.fallbackActivity({ initialContext }), + activityRoute.activityName === fallbackActivityName, )!; + })(); const pattern = new UrlPattern( `${targetActivityRoute.path}(/)`, options.urlPatternOptions,