From 75370d226da8222985b3f108a5b14e69a88a5d61 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Fri, 24 Jul 2026 15:23:41 +0900 Subject: [PATCH 1/8] feat(core): expose prevented action state to pre-effect hooks --- core/src/interfaces/StackflowPluginHook.ts | 1 + core/src/utils/triggerPreEffectHooks.ts | 1 + docs/pages/docs/advanced/write-plugin.en.mdx | 3 +++ docs/pages/docs/advanced/write-plugin.ko.mdx | 3 +++ 4 files changed, 8 insertions(+) diff --git a/core/src/interfaces/StackflowPluginHook.ts b/core/src/interfaces/StackflowPluginHook.ts index f2656ef9a..8c3853844 100644 --- a/core/src/interfaces/StackflowPluginHook.ts +++ b/core/src/interfaces/StackflowPluginHook.ts @@ -18,6 +18,7 @@ export type StackflowPluginInitHook = (args: { export type StackflowPluginPreEffectHook = (args: { actionParams: T; actions: StackflowActions & { + isPrevented: () => boolean; preventDefault: () => void; overrideActionParams: (params: T) => void; }; diff --git a/core/src/utils/triggerPreEffectHooks.ts b/core/src/utils/triggerPreEffectHooks.ts index 1e0433d3b..a82223c95 100644 --- a/core/src/utils/triggerPreEffectHooks.ts +++ b/core/src/utils/triggerPreEffectHooks.ts @@ -47,6 +47,7 @@ export function triggerPreEffectHook( actionParams: { ...nextActionParams }, actions: { ...actions, + isPrevented: () => isPrevented, preventDefault: () => { isPrevented = true; }, diff --git a/docs/pages/docs/advanced/write-plugin.en.mdx b/docs/pages/docs/advanced/write-plugin.en.mdx index 7f6582b1a..3aee29605 100644 --- a/docs/pages/docs/advanced/write-plugin.en.mdx +++ b/docs/pages/docs/advanced/write-plugin.en.mdx @@ -251,11 +251,14 @@ Pre-effect hooks include `onBeforePush`, `onBeforeReplace`, and `onBeforePop`. P | | | | | ---------------------- | ---------- | ------------------------------------------ | | actions.preventDefault | `function` | Cancel the default behavior. | +| actions.isPrevented | `function` | Check whether the action is prevented. | | actions.getStack | `function` | Get the current stack state. | | actions.dispatchEvent | `function` | Add a new event to the core. | | effect | `object` | The effect that triggered the effect hook. | +Calling `preventDefault()` cancels the default event dispatch, but it does not stop the remaining pre-effect hooks. Those hooks can call `isPrevented()` to observe the current action-local state. + ## Determining initial activity You can override the existing `initialActivity` behavior through the `overrideInitialEvents` API. diff --git a/docs/pages/docs/advanced/write-plugin.ko.mdx b/docs/pages/docs/advanced/write-plugin.ko.mdx index 73ba436d0..9cc56e1e9 100644 --- a/docs/pages/docs/advanced/write-plugin.ko.mdx +++ b/docs/pages/docs/advanced/write-plugin.ko.mdx @@ -259,11 +259,14 @@ Pre-effect 훅에는 `onBeforePush`, `onBeforeReplace`, `onBeforePop`이 있어 | | | | | ---------------------- | ---------- | ----------------------------- | | actions.preventDefault | `function` | (추가) 기본 동작을 취소해요. | +| actions.isPrevented | `function` | 현재 action의 예방 상태를 확인해요. | | actions.getStack | `function` | 현재 스택의 상태를 가져올 수 있어요. | | actions.dispatchEvent | `function` | 코어에 새 이벤트를 추가해요. | | effect | `object` | 해당 이펙트 훅을 촉발시킨 이펙트에요. | +`preventDefault()`를 호출하면 기본 이벤트 dispatch는 취소되지만 남은 pre-effect 훅은 계속 실행돼요. 후속 훅은 `isPrevented()`를 호출해 현재 action에 한정된 예방 상태를 확인할 수 있어요. + ## 첫 액티비티 결정하기 `overrideInitialEvents` API를 통해 기존에 존재하는 `initialActivity` 동작을 덮어쓸 수 있어요. From 3b99fc5c9398faadb32dd436ef2bd785c06cdfe8 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Fri, 24 Jul 2026 15:24:00 +0900 Subject: [PATCH 2/8] fix(react): skip loader work for prevented actions --- integrations/react/src/loader/loaderPlugin.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/integrations/react/src/loader/loaderPlugin.tsx b/integrations/react/src/loader/loaderPlugin.tsx index 59087dd0c..22c52b607 100644 --- a/integrations/react/src/loader/loaderPlugin.tsx +++ b/integrations/react/src/loader/loaderPlugin.tsx @@ -206,10 +206,12 @@ function createBeforeRouteHandler< input: StackflowInput, loadData: (activityName: string, activityParams: {}) => unknown, ): OnBeforeRoute { - return ({ - actionParams, - actions: { overrideActionParams, pause, resume }, - }) => { + return ({ actionParams, actions }) => { + if (typeof actions.isPrevented === "function" && actions.isPrevented()) { + return; + } + + const { overrideActionParams, pause, resume } = actions; const { activityName, activityParams, activityContext } = actionParams; const matchActivity = input.config.activities.find( From 491f28f009cf94b74baf7e59fc8fa1e47c5e6449 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Fri, 24 Jul 2026 15:24:15 +0900 Subject: [PATCH 3/8] chore: add prevented loader action changeset --- .changeset/fep-2635-prevented-loader.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/fep-2635-prevented-loader.md diff --git a/.changeset/fep-2635-prevented-loader.md b/.changeset/fep-2635-prevented-loader.md new file mode 100644 index 000000000..208c6429d --- /dev/null +++ b/.changeset/fep-2635-prevented-loader.md @@ -0,0 +1,6 @@ +--- +"@stackflow/core": minor +"@stackflow/react": patch +--- + +Expose the live, action-local `actions.isPrevented()` state to every pre-effect hook, and make the React loader skip loader, preload, and pause work for prevented actions. Update both packages together to enable the new contract while preserving normal navigation with older Core runtimes. From 1d7326a50c9fb1dc5c0aa6e22f6daa6f97b3f1ca Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Fri, 24 Jul 2026 15:53:00 +0900 Subject: [PATCH 4/8] docs: record prevented action contract --- CONTEXT.md | 9 + ...-actions-without-short-circuiting-hooks.md | 3 + .../plan.md | 184 ++++++++++++++++++ 3 files changed, 196 insertions(+) create mode 100644 CONTEXT.md create mode 100644 docs/adr/0001-observe-prevented-actions-without-short-circuiting-hooks.md create mode 100644 plans/fep-2635-plugin-loader-preventdefault-support/plan.md diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 000000000..7e1adb62d --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,9 @@ +# Stackflow Navigation + +Stackflow에서 navigation 요청이 Core의 domain event가 되기 전까지 적용되는 계약을 정의한다. + +## Language + +**Prevented Action**: +Pre-effect hook이 domain event dispatch 전에 취소한 action. 기본 dispatch는 취소되지만 남은 pre-effect hook의 실행은 끝까지 이어진다. +_Avoid_: Blocked navigation, cancelled event diff --git a/docs/adr/0001-observe-prevented-actions-without-short-circuiting-hooks.md b/docs/adr/0001-observe-prevented-actions-without-short-circuiting-hooks.md new file mode 100644 index 000000000..c8742d101 --- /dev/null +++ b/docs/adr/0001-observe-prevented-actions-without-short-circuiting-hooks.md @@ -0,0 +1,3 @@ +# Observe prevented actions without short-circuiting hooks + +Pre-effect hooks continue in plugin order after `preventDefault()` because downstream plugins may still need to observe or transform the action. Core exposes the action-local state through `actions.isPrevented()` on every pre-effect hook so a downstream plugin can skip work that is invalid for a Prevented Action; this was chosen over stopping hook traversal, which would change established plugin ordering and blocker replay behavior. diff --git a/plans/fep-2635-plugin-loader-preventdefault-support/plan.md b/plans/fep-2635-plugin-loader-preventdefault-support/plan.md new file mode 100644 index 000000000..d68a45606 --- /dev/null +++ b/plans/fep-2635-plugin-loader-preventdefault-support/plan.md @@ -0,0 +1,184 @@ +# FEP-2635 작업 계획 — Prevented Action을 인지하는 plugin-loader + +- 이슈: [FEP-2635](https://linear.app/daangn/issue/FEP-2635) + — `plugin-loader`가 preventDefault된 push/replace의 loader와 lazy preload를 실행하고 + Stack을 pause함 +- 상태: 스펙 확정 (2026-07-24, 인터뷰 완료) + +## 목표 + +사용자 플러그인이 `push` 또는 `replace`를 `preventDefault()`한 경우, 뒤에서 실행되는 +React 내장 `plugin-loader`가 해당 action을 관찰만 하고 다음 작업은 전혀 시작하지 +않도록 한다. + +- Activity loader를 호출하지 않는다. +- lazy Activity component preload를 호출하지 않는다. +- `pause()`/`resume()`을 호출하지 않는다. +- loaderData를 `activityContext`에 주입하지 않는다. +- 원래 `Pushed`/`Replaced` event는 지금과 같이 dispatch하지 않는다. + +동시에 `preventDefault()` 이후에도 남은 pre-effect hook은 plugin 순서대로 계속 +실행한다. 이번 수정은 hook pipeline을 중단하는 변경이 아니라, 각 hook이 현재 action의 +예방 상태를 판별할 수 있게 하는 변경이다. + +## 현재 동작의 근거 + +- `integrations/react/src/stackflow.tsx:79`에서 사용자 플러그인을 먼저 놓고, + `integrations/react/src/stackflow.tsx:87`에서 내장 `loaderPlugin`을 마지막에 + 추가한다. +- `core/src/utils/triggerPreEffectHooks.ts:38`의 action-local `isPrevented`는 + `preventDefault()`가 호출되면 `true`가 되지만, `core/src/utils/triggerPreEffectHooks.ts:41` + 의 plugin 순회는 계속된다. +- `core/src/utils/makeActions.ts:20`과 `core/src/utils/makeActions.ts:34`는 모든 + pre-effect hook이 반환된 뒤에야 예방 상태를 확인하고 push/replace dispatch를 + 생략한다. +- `integrations/react/src/loader/loaderPlugin.tsx:224`부터 loader와 lazy preload를 + 먼저 실행하고, pending이면 `integrations/react/src/loader/loaderPlugin.tsx:244`에서 + Stack을 pause한다. 이 hook에는 현재 예방 상태를 읽을 방법이 없다. +- `extensions/plugin-blocker/src/blockerPlugin.spec.tsx:1987`의 기존 계약 테스트는 + blocker 뒤의 플러그인도 예방된 push의 `onBeforePush`를 받는다고 명시한다. + +## 확정된 해결 계약 + +1. **Prevented Action의 의미를 유지한다.** + `preventDefault()`는 기본 domain event dispatch를 취소하지만 pre-effect hook + pipeline을 중단하지 않는다. +2. **모든 pre-effect hook이 예방 상태를 읽을 수 있다.** + 공통 hook action API에 `actions.isPrevented(): boolean`을 추가한다. +3. **예방 상태는 action-local이며 live하다.** + 같은 hook에서 `preventDefault()`를 호출한 직후에는 `true`를 반환하고, 중첩 action은 + 바깥 action과 독립된 상태를 가진다. +4. **loader는 이미 예방된 action에서 즉시 종료한다.** + 대상 Activity 조회, loader 실행, component preload, pause/resume 예약, + `overrideActionParams()`보다 먼저 판별한다. +5. **후속 hook의 기존 권한은 바꾸지 않는다.** + Prevented Action을 받은 다른 플러그인은 계속 action을 관찰하거나 + `overrideActionParams()`를 호출할 수 있다. 다만 최종 dispatch는 계속 취소된다. +6. **정상 navigation의 loader 계약은 바꾸지 않는다.** + 예방되지 않은 push/replace의 loader, lazy preload, immediate-render 판정, + pause/resume, loaderData 전달 방식은 그대로 둔다. +7. **테스트 코드를 추가하거나 수정하지 않는다.** + 저장소에 새 spec, test harness, test 설정을 남기지 않는다. + +## 구현 계획 + +### 1. Core에 action-local 예방 상태 조회 API 추가 + +대상: + +- `core/src/interfaces/StackflowPluginHook.ts` +- `core/src/utils/triggerPreEffectHooks.ts` + +작업: + +- `StackflowPluginPreEffectHook`의 `actions`에 + `isPrevented: () => boolean`을 추가한다. +- `triggerPreEffectHook()`가 이미 소유한 action-local 예방 상태를 읽는 closure를 모든 + pre-effect hook에 전달한다. +- closure는 현재 hook 호출 시점의 snapshot이 아니라 현재 action invocation의 live + 값을 반환하게 한다. +- hook 순회, action param 누적 override, 최종 `PreEffectHookResult.isPrevented`와 + `makeActions()`의 dispatch 생략 로직은 변경하지 않는다. + +### 2. React 내장 plugin-loader의 작업 시작 경계 수정 + +대상: + +- `integrations/react/src/loader/loaderPlugin.tsx` + +작업: + +- `createBeforeRouteHandler()`가 받은 hook action에서 예방 상태를 가장 먼저 확인한다. +- 이미 예방됐다면 loader/preload/pause/override 경로에 진입하지 않고 반환한다. +- 내장 loader의 등록 순서는 바꾸지 않는다. 마지막에 실행되어야 앞선 사용자 플러그인이 + 만든 최종 예방 상태와 action param override를 모두 볼 수 있다. +- `@stackflow/react`의 현재 Core peer 범위(`^2.0.0 || ^3.0.0`)는 유지한다. + 구버전 Core 런타임에는 조회 함수가 없을 수 있으므로 capability를 방어적으로 확인한다. + 새 예방 계약은 이 API를 제공하는 Core와 React 버전을 함께 사용할 때 활성화되며, + 구버전 Core에서 정상 navigation이 깨지도록 만들지 않는다. + +### 3. 공개 plugin API 문서 갱신 + +대상: + +- `docs/pages/docs/advanced/write-plugin.en.mdx` +- `docs/pages/docs/advanced/write-plugin.ko.mdx` + +작업: + +- pre-effect hook action 표에 `actions.isPrevented`를 추가한다. +- `preventDefault()`가 이후 hook 실행까지 중단하는 API가 아니라는 점과, 후속 hook이 + `isPrevented()`로 상태를 판별할 수 있다는 점을 짧게 명시한다. + +### 4. 릴리즈 메타데이터 추가 + +대상: + +- `.changeset/.md` + +작업: + +- `@stackflow/core`: **minor** — 공개 pre-effect hook API 추가. +- `@stackflow/react`: **patch** — Prevented Action에서 loader의 작업과 Stack pause를 + 시작하던 버그 수정. +- 두 패키지를 함께 갱신해야 새 예방 상태 전달 계약이 완성된다는 점을 changeset 본문에 + 드러낸다. + +## 검증 계획 + +저장소 테스트 파일은 만들거나 수정하지 않는다. 구현자는 `/tmp/FEP-2635/` 아래의 +일회성 실행 파일과 기존 명령만 사용해 다음을 검증하고, 저장소 diff에는 검증용 파일을 +남기지 않는다. + +### Runtime 확인 + +`makeCoreStore()`에 예방 플러그인과 실제 `loaderPlugin()`을 순서대로 등록한 +throwaway harness로 push와 replace를 각각 확인한다. + +- 예방된 action: + - 앞선 hook에서 `isPrevented()`는 처음에 `false`, `preventDefault()` 직후 `true` + - 뒤의 hook과 loader hook에서 `true` + - loader 호출 0회, lazy `_load()`/structured content preload 호출 0회 + - 새 `Paused`, `Resumed`, `Pushed`, `Replaced` event 없음 + - Activity 목록 불변, `globalTransitionState === "idle"`, `pausedEvents` 없음 +- 정상 action 대조군: + - loader와 preload가 기존과 같이 실행됨 + - pending 작업이면 pause되고 navigation event가 queue됨 + - 작업 완료 후 resume되어 navigation이 적용됨 +- 중첩 action: + - 안쪽 action의 예방 여부가 바깥 action의 조회 결과를 오염시키지 않음 + +### 기존 검증 명령 + +1. `yarn workspace @stackflow/core test` +2. `yarn workspace @stackflow/core typecheck` +3. `yarn workspace @stackflow/core build` +4. `yarn workspace @stackflow/react typecheck` +5. `yarn workspace @stackflow/react build` +6. `yarn workspace @stackflow/plugin-blocker test` +7. `yarn lint` +8. `yarn changeset status` +9. `git diff --check` + +## 비목표 + +- [FEP-2636](https://linear.app/daangn/issue/FEP-2636)의 + `Paused → Resumed` 사이에 queued event가 없을 때 Stack이 paused에 남는 Core + reducer 결함 수정 +- `preventDefault()` 이후의 pre-effect hook 순회 중단 +- `preventDefault()`를 되돌리는 `allowDefault()` 계열 API +- plugin 등록 순서 변경 +- loader, lazy component, Suspense 또는 immediate-render 정책 변경 +- `plugin-blocker` 구현이나 테스트 변경 +- 구버전 Core에 새 예방 상태 조회 기능을 역으로 주입하는 compatibility shim + +## 산출물과 커밋 경계 + +- 설계 문서: `CONTEXT.md`, + `docs/adr/0001-observe-prevented-actions-without-short-circuiting-hooks.md`, 이 계획 +- Core 공개 API와 문서 +- React loader bug fix +- Core minor + React patch changeset + +이미 존재하는 커밋은 수정하지 않는다. 구현 변경은 새 커밋으로 쌓고, 최소한 +Core API와 React bug fix의 의미 경계가 diff에서 구분되도록 한다. From 71f227a5512ab90c379934b34421aa9b657d5175 Mon Sep 17 00:00:00 2001 From: ENvironmentSet Date: Fri, 24 Jul 2026 17:42:22 +0900 Subject: [PATCH 5/8] fix: address prevented loader review feedback --- .../fep-2635-observe-prevented-actions.md | 5 +++ .changeset/fep-2635-prevented-loader.md | 6 ---- .../fep-2635-skip-prevented-loader-work.md | 5 +++ docs/pages/docs/advanced/write-plugin.ko.mdx | 4 +-- integrations/react/package.json | 2 +- .../react/src/loader/loaderPlugin.tsx | 2 +- .../plan.md | 36 +++++++++---------- yarn.lock | 2 +- 8 files changed, 32 insertions(+), 30 deletions(-) create mode 100644 .changeset/fep-2635-observe-prevented-actions.md delete mode 100644 .changeset/fep-2635-prevented-loader.md create mode 100644 .changeset/fep-2635-skip-prevented-loader-work.md diff --git a/.changeset/fep-2635-observe-prevented-actions.md b/.changeset/fep-2635-observe-prevented-actions.md new file mode 100644 index 000000000..3605281fb --- /dev/null +++ b/.changeset/fep-2635-observe-prevented-actions.md @@ -0,0 +1,5 @@ +--- +"@stackflow/core": minor +--- + +Expose the live, action-local `actions.isPrevented()` state to every pre-effect hook without stopping the remaining hooks. diff --git a/.changeset/fep-2635-prevented-loader.md b/.changeset/fep-2635-prevented-loader.md deleted file mode 100644 index 208c6429d..000000000 --- a/.changeset/fep-2635-prevented-loader.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@stackflow/core": minor -"@stackflow/react": patch ---- - -Expose the live, action-local `actions.isPrevented()` state to every pre-effect hook, and make the React loader skip loader, preload, and pause work for prevented actions. Update both packages together to enable the new contract while preserving normal navigation with older Core runtimes. diff --git a/.changeset/fep-2635-skip-prevented-loader-work.md b/.changeset/fep-2635-skip-prevented-loader-work.md new file mode 100644 index 000000000..b020735bc --- /dev/null +++ b/.changeset/fep-2635-skip-prevented-loader-work.md @@ -0,0 +1,5 @@ +--- +"@stackflow/react": patch +--- + +Require `@stackflow/core` v3 and skip loader, preload, and pause work for prevented push and replace actions. diff --git a/docs/pages/docs/advanced/write-plugin.ko.mdx b/docs/pages/docs/advanced/write-plugin.ko.mdx index 9cc56e1e9..456c52bd9 100644 --- a/docs/pages/docs/advanced/write-plugin.ko.mdx +++ b/docs/pages/docs/advanced/write-plugin.ko.mdx @@ -259,13 +259,13 @@ Pre-effect 훅에는 `onBeforePush`, `onBeforeReplace`, `onBeforePop`이 있어 | | | | | ---------------------- | ---------- | ----------------------------- | | actions.preventDefault | `function` | (추가) 기본 동작을 취소해요. | -| actions.isPrevented | `function` | 현재 action의 예방 상태를 확인해요. | +| actions.isPrevented | `function` | 현재 action의 취소 상태를 확인해요. | | actions.getStack | `function` | 현재 스택의 상태를 가져올 수 있어요. | | actions.dispatchEvent | `function` | 코어에 새 이벤트를 추가해요. | | effect | `object` | 해당 이펙트 훅을 촉발시킨 이펙트에요. | -`preventDefault()`를 호출하면 기본 이벤트 dispatch는 취소되지만 남은 pre-effect 훅은 계속 실행돼요. 후속 훅은 `isPrevented()`를 호출해 현재 action에 한정된 예방 상태를 확인할 수 있어요. +`preventDefault()`를 호출하면 기본 이벤트 dispatch는 취소되지만 남은 pre-effect 훅은 계속 실행돼요. 후속 훅은 `isPrevented()`를 호출해 현재 action에 한정된 취소 상태를 확인할 수 있어요. ## 첫 액티비티 결정하기 diff --git a/integrations/react/package.json b/integrations/react/package.json index a111cfa2c..01c80ee6f 100644 --- a/integrations/react/package.json +++ b/integrations/react/package.json @@ -46,7 +46,7 @@ }, "peerDependencies": { "@stackflow/config": "^2.0.0", - "@stackflow/core": "^2.0.0 || ^3.0.0", + "@stackflow/core": "^3.0.0", "@types/react": ">=16.8.0", "react": ">=16.8.0" }, diff --git a/integrations/react/src/loader/loaderPlugin.tsx b/integrations/react/src/loader/loaderPlugin.tsx index 22c52b607..a06623cfe 100644 --- a/integrations/react/src/loader/loaderPlugin.tsx +++ b/integrations/react/src/loader/loaderPlugin.tsx @@ -207,7 +207,7 @@ function createBeforeRouteHandler< loadData: (activityName: string, activityParams: {}) => unknown, ): OnBeforeRoute { return ({ actionParams, actions }) => { - if (typeof actions.isPrevented === "function" && actions.isPrevented()) { + if (actions.isPrevented()) { return; } diff --git a/plans/fep-2635-plugin-loader-preventdefault-support/plan.md b/plans/fep-2635-plugin-loader-preventdefault-support/plan.md index d68a45606..a97725ba7 100644 --- a/plans/fep-2635-plugin-loader-preventdefault-support/plan.md +++ b/plans/fep-2635-plugin-loader-preventdefault-support/plan.md @@ -3,7 +3,7 @@ - 이슈: [FEP-2635](https://linear.app/daangn/issue/FEP-2635) — `plugin-loader`가 preventDefault된 push/replace의 loader와 lazy preload를 실행하고 Stack을 pause함 -- 상태: 스펙 확정 (2026-07-24, 인터뷰 완료) +- 상태: 구현 리뷰 반영 (2026-07-24) ## 목표 @@ -19,7 +19,7 @@ React 내장 `plugin-loader`가 해당 action을 관찰만 하고 다음 작업 동시에 `preventDefault()` 이후에도 남은 pre-effect hook은 plugin 순서대로 계속 실행한다. 이번 수정은 hook pipeline을 중단하는 변경이 아니라, 각 hook이 현재 action의 -예방 상태를 판별할 수 있게 하는 변경이다. +취소 상태를 판별할 수 있게 하는 변경이다. ## 현재 동작의 근거 @@ -30,11 +30,11 @@ React 내장 `plugin-loader`가 해당 action을 관찰만 하고 다음 작업 `preventDefault()`가 호출되면 `true`가 되지만, `core/src/utils/triggerPreEffectHooks.ts:41` 의 plugin 순회는 계속된다. - `core/src/utils/makeActions.ts:20`과 `core/src/utils/makeActions.ts:34`는 모든 - pre-effect hook이 반환된 뒤에야 예방 상태를 확인하고 push/replace dispatch를 + pre-effect hook이 반환된 뒤에야 취소 상태를 확인하고 push/replace dispatch를 생략한다. - `integrations/react/src/loader/loaderPlugin.tsx:224`부터 loader와 lazy preload를 먼저 실행하고, pending이면 `integrations/react/src/loader/loaderPlugin.tsx:244`에서 - Stack을 pause한다. 이 hook에는 현재 예방 상태를 읽을 방법이 없다. + Stack을 pause한다. 이 hook에는 현재 취소 상태를 읽을 방법이 없다. - `extensions/plugin-blocker/src/blockerPlugin.spec.tsx:1987`의 기존 계약 테스트는 blocker 뒤의 플러그인도 예방된 push의 `onBeforePush`를 받는다고 명시한다. @@ -43,9 +43,9 @@ React 내장 `plugin-loader`가 해당 action을 관찰만 하고 다음 작업 1. **Prevented Action의 의미를 유지한다.** `preventDefault()`는 기본 domain event dispatch를 취소하지만 pre-effect hook pipeline을 중단하지 않는다. -2. **모든 pre-effect hook이 예방 상태를 읽을 수 있다.** +2. **모든 pre-effect hook이 취소 상태를 읽을 수 있다.** 공통 hook action API에 `actions.isPrevented(): boolean`을 추가한다. -3. **예방 상태는 action-local이며 live하다.** +3. **취소 상태는 action-local이며 live하다.** 같은 hook에서 `preventDefault()`를 호출한 직후에는 `true`를 반환하고, 중첩 action은 바깥 action과 독립된 상태를 가진다. 4. **loader는 이미 예방된 action에서 즉시 종료한다.** @@ -62,7 +62,7 @@ React 내장 `plugin-loader`가 해당 action을 관찰만 하고 다음 작업 ## 구현 계획 -### 1. Core에 action-local 예방 상태 조회 API 추가 +### 1. Core에 action-local 취소 상태 조회 API 추가 대상: @@ -73,7 +73,7 @@ React 내장 `plugin-loader`가 해당 action을 관찰만 하고 다음 작업 - `StackflowPluginPreEffectHook`의 `actions`에 `isPrevented: () => boolean`을 추가한다. -- `triggerPreEffectHook()`가 이미 소유한 action-local 예방 상태를 읽는 closure를 모든 +- `triggerPreEffectHook()`가 이미 소유한 action-local 취소 상태를 읽는 closure를 모든 pre-effect hook에 전달한다. - closure는 현재 hook 호출 시점의 snapshot이 아니라 현재 action invocation의 live 값을 반환하게 한다. @@ -88,14 +88,12 @@ React 내장 `plugin-loader`가 해당 action을 관찰만 하고 다음 작업 작업: -- `createBeforeRouteHandler()`가 받은 hook action에서 예방 상태를 가장 먼저 확인한다. +- `createBeforeRouteHandler()`가 받은 hook action에서 취소 상태를 가장 먼저 확인한다. - 이미 예방됐다면 loader/preload/pause/override 경로에 진입하지 않고 반환한다. - 내장 loader의 등록 순서는 바꾸지 않는다. 마지막에 실행되어야 앞선 사용자 플러그인이 - 만든 최종 예방 상태와 action param override를 모두 볼 수 있다. -- `@stackflow/react`의 현재 Core peer 범위(`^2.0.0 || ^3.0.0`)는 유지한다. - 구버전 Core 런타임에는 조회 함수가 없을 수 있으므로 capability를 방어적으로 확인한다. - 새 예방 계약은 이 API를 제공하는 Core와 React 버전을 함께 사용할 때 활성화되며, - 구버전 Core에서 정상 navigation이 깨지도록 만들지 않는다. + 만든 최종 취소 상태와 action param override를 모두 볼 수 있다. +- `@stackflow/react`의 Core peer 범위를 `^3.0.0`으로 올리고 새 API를 직접 사용한다. + 새 예방 계약은 Core와 React를 함께 갱신할 때 활성화된다. ### 3. 공개 plugin API 문서 갱신 @@ -114,15 +112,15 @@ React 내장 `plugin-loader`가 해당 action을 관찰만 하고 다음 작업 대상: -- `.changeset/.md` +- `.changeset/fep-2635-observe-prevented-actions.md` +- `.changeset/fep-2635-skip-prevented-loader-work.md` 작업: - `@stackflow/core`: **minor** — 공개 pre-effect hook API 추가. - `@stackflow/react`: **patch** — Prevented Action에서 loader의 작업과 Stack pause를 - 시작하던 버그 수정. -- 두 패키지를 함께 갱신해야 새 예방 상태 전달 계약이 완성된다는 점을 changeset 본문에 - 드러낸다. + 시작하던 버그 수정 및 Core v3 peer 계약 반영. +- 패키지별 릴리즈 내용을 독립된 changeset으로 기록한다. ## 검증 계획 @@ -170,7 +168,7 @@ throwaway harness로 push와 replace를 각각 확인한다. - plugin 등록 순서 변경 - loader, lazy component, Suspense 또는 immediate-render 정책 변경 - `plugin-blocker` 구현이나 테스트 변경 -- 구버전 Core에 새 예방 상태 조회 기능을 역으로 주입하는 compatibility shim +- 구버전 Core 호환을 위한 capability check나 compatibility shim ## 산출물과 커밋 경계 diff --git a/yarn.lock b/yarn.lock index f14bc9ec6..8dd887e02 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6068,7 +6068,7 @@ __metadata: typescript: "npm:^5.5.3" peerDependencies: "@stackflow/config": ^2.0.0 - "@stackflow/core": ^2.0.0 || ^3.0.0 + "@stackflow/core": ^3.0.0 "@types/react": ">=16.8.0" react: ">=16.8.0" languageName: unknown From b2a1d4860bdaf1b0ca69130fdc5dc20e604706b3 Mon Sep 17 00:00:00 2001 From: Jaewon Seo Date: Mon, 27 Jul 2026 01:34:59 +0900 Subject: [PATCH 6/8] Delete CONTEXT.md --- CONTEXT.md | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 CONTEXT.md diff --git a/CONTEXT.md b/CONTEXT.md deleted file mode 100644 index 7e1adb62d..000000000 --- a/CONTEXT.md +++ /dev/null @@ -1,9 +0,0 @@ -# Stackflow Navigation - -Stackflow에서 navigation 요청이 Core의 domain event가 되기 전까지 적용되는 계약을 정의한다. - -## Language - -**Prevented Action**: -Pre-effect hook이 domain event dispatch 전에 취소한 action. 기본 dispatch는 취소되지만 남은 pre-effect hook의 실행은 끝까지 이어진다. -_Avoid_: Blocked navigation, cancelled event From c8da46752de26a9131f5e4b188f761370e06db21 Mon Sep 17 00:00:00 2001 From: Jaewon Seo Date: Mon, 27 Jul 2026 01:35:40 +0900 Subject: [PATCH 7/8] Delete plans/fep-2635-plugin-loader-preventdefault-support/plan.md --- .../plan.md | 182 ------------------ 1 file changed, 182 deletions(-) delete mode 100644 plans/fep-2635-plugin-loader-preventdefault-support/plan.md diff --git a/plans/fep-2635-plugin-loader-preventdefault-support/plan.md b/plans/fep-2635-plugin-loader-preventdefault-support/plan.md deleted file mode 100644 index a97725ba7..000000000 --- a/plans/fep-2635-plugin-loader-preventdefault-support/plan.md +++ /dev/null @@ -1,182 +0,0 @@ -# FEP-2635 작업 계획 — Prevented Action을 인지하는 plugin-loader - -- 이슈: [FEP-2635](https://linear.app/daangn/issue/FEP-2635) - — `plugin-loader`가 preventDefault된 push/replace의 loader와 lazy preload를 실행하고 - Stack을 pause함 -- 상태: 구현 리뷰 반영 (2026-07-24) - -## 목표 - -사용자 플러그인이 `push` 또는 `replace`를 `preventDefault()`한 경우, 뒤에서 실행되는 -React 내장 `plugin-loader`가 해당 action을 관찰만 하고 다음 작업은 전혀 시작하지 -않도록 한다. - -- Activity loader를 호출하지 않는다. -- lazy Activity component preload를 호출하지 않는다. -- `pause()`/`resume()`을 호출하지 않는다. -- loaderData를 `activityContext`에 주입하지 않는다. -- 원래 `Pushed`/`Replaced` event는 지금과 같이 dispatch하지 않는다. - -동시에 `preventDefault()` 이후에도 남은 pre-effect hook은 plugin 순서대로 계속 -실행한다. 이번 수정은 hook pipeline을 중단하는 변경이 아니라, 각 hook이 현재 action의 -취소 상태를 판별할 수 있게 하는 변경이다. - -## 현재 동작의 근거 - -- `integrations/react/src/stackflow.tsx:79`에서 사용자 플러그인을 먼저 놓고, - `integrations/react/src/stackflow.tsx:87`에서 내장 `loaderPlugin`을 마지막에 - 추가한다. -- `core/src/utils/triggerPreEffectHooks.ts:38`의 action-local `isPrevented`는 - `preventDefault()`가 호출되면 `true`가 되지만, `core/src/utils/triggerPreEffectHooks.ts:41` - 의 plugin 순회는 계속된다. -- `core/src/utils/makeActions.ts:20`과 `core/src/utils/makeActions.ts:34`는 모든 - pre-effect hook이 반환된 뒤에야 취소 상태를 확인하고 push/replace dispatch를 - 생략한다. -- `integrations/react/src/loader/loaderPlugin.tsx:224`부터 loader와 lazy preload를 - 먼저 실행하고, pending이면 `integrations/react/src/loader/loaderPlugin.tsx:244`에서 - Stack을 pause한다. 이 hook에는 현재 취소 상태를 읽을 방법이 없다. -- `extensions/plugin-blocker/src/blockerPlugin.spec.tsx:1987`의 기존 계약 테스트는 - blocker 뒤의 플러그인도 예방된 push의 `onBeforePush`를 받는다고 명시한다. - -## 확정된 해결 계약 - -1. **Prevented Action의 의미를 유지한다.** - `preventDefault()`는 기본 domain event dispatch를 취소하지만 pre-effect hook - pipeline을 중단하지 않는다. -2. **모든 pre-effect hook이 취소 상태를 읽을 수 있다.** - 공통 hook action API에 `actions.isPrevented(): boolean`을 추가한다. -3. **취소 상태는 action-local이며 live하다.** - 같은 hook에서 `preventDefault()`를 호출한 직후에는 `true`를 반환하고, 중첩 action은 - 바깥 action과 독립된 상태를 가진다. -4. **loader는 이미 예방된 action에서 즉시 종료한다.** - 대상 Activity 조회, loader 실행, component preload, pause/resume 예약, - `overrideActionParams()`보다 먼저 판별한다. -5. **후속 hook의 기존 권한은 바꾸지 않는다.** - Prevented Action을 받은 다른 플러그인은 계속 action을 관찰하거나 - `overrideActionParams()`를 호출할 수 있다. 다만 최종 dispatch는 계속 취소된다. -6. **정상 navigation의 loader 계약은 바꾸지 않는다.** - 예방되지 않은 push/replace의 loader, lazy preload, immediate-render 판정, - pause/resume, loaderData 전달 방식은 그대로 둔다. -7. **테스트 코드를 추가하거나 수정하지 않는다.** - 저장소에 새 spec, test harness, test 설정을 남기지 않는다. - -## 구현 계획 - -### 1. Core에 action-local 취소 상태 조회 API 추가 - -대상: - -- `core/src/interfaces/StackflowPluginHook.ts` -- `core/src/utils/triggerPreEffectHooks.ts` - -작업: - -- `StackflowPluginPreEffectHook`의 `actions`에 - `isPrevented: () => boolean`을 추가한다. -- `triggerPreEffectHook()`가 이미 소유한 action-local 취소 상태를 읽는 closure를 모든 - pre-effect hook에 전달한다. -- closure는 현재 hook 호출 시점의 snapshot이 아니라 현재 action invocation의 live - 값을 반환하게 한다. -- hook 순회, action param 누적 override, 최종 `PreEffectHookResult.isPrevented`와 - `makeActions()`의 dispatch 생략 로직은 변경하지 않는다. - -### 2. React 내장 plugin-loader의 작업 시작 경계 수정 - -대상: - -- `integrations/react/src/loader/loaderPlugin.tsx` - -작업: - -- `createBeforeRouteHandler()`가 받은 hook action에서 취소 상태를 가장 먼저 확인한다. -- 이미 예방됐다면 loader/preload/pause/override 경로에 진입하지 않고 반환한다. -- 내장 loader의 등록 순서는 바꾸지 않는다. 마지막에 실행되어야 앞선 사용자 플러그인이 - 만든 최종 취소 상태와 action param override를 모두 볼 수 있다. -- `@stackflow/react`의 Core peer 범위를 `^3.0.0`으로 올리고 새 API를 직접 사용한다. - 새 예방 계약은 Core와 React를 함께 갱신할 때 활성화된다. - -### 3. 공개 plugin API 문서 갱신 - -대상: - -- `docs/pages/docs/advanced/write-plugin.en.mdx` -- `docs/pages/docs/advanced/write-plugin.ko.mdx` - -작업: - -- pre-effect hook action 표에 `actions.isPrevented`를 추가한다. -- `preventDefault()`가 이후 hook 실행까지 중단하는 API가 아니라는 점과, 후속 hook이 - `isPrevented()`로 상태를 판별할 수 있다는 점을 짧게 명시한다. - -### 4. 릴리즈 메타데이터 추가 - -대상: - -- `.changeset/fep-2635-observe-prevented-actions.md` -- `.changeset/fep-2635-skip-prevented-loader-work.md` - -작업: - -- `@stackflow/core`: **minor** — 공개 pre-effect hook API 추가. -- `@stackflow/react`: **patch** — Prevented Action에서 loader의 작업과 Stack pause를 - 시작하던 버그 수정 및 Core v3 peer 계약 반영. -- 패키지별 릴리즈 내용을 독립된 changeset으로 기록한다. - -## 검증 계획 - -저장소 테스트 파일은 만들거나 수정하지 않는다. 구현자는 `/tmp/FEP-2635/` 아래의 -일회성 실행 파일과 기존 명령만 사용해 다음을 검증하고, 저장소 diff에는 검증용 파일을 -남기지 않는다. - -### Runtime 확인 - -`makeCoreStore()`에 예방 플러그인과 실제 `loaderPlugin()`을 순서대로 등록한 -throwaway harness로 push와 replace를 각각 확인한다. - -- 예방된 action: - - 앞선 hook에서 `isPrevented()`는 처음에 `false`, `preventDefault()` 직후 `true` - - 뒤의 hook과 loader hook에서 `true` - - loader 호출 0회, lazy `_load()`/structured content preload 호출 0회 - - 새 `Paused`, `Resumed`, `Pushed`, `Replaced` event 없음 - - Activity 목록 불변, `globalTransitionState === "idle"`, `pausedEvents` 없음 -- 정상 action 대조군: - - loader와 preload가 기존과 같이 실행됨 - - pending 작업이면 pause되고 navigation event가 queue됨 - - 작업 완료 후 resume되어 navigation이 적용됨 -- 중첩 action: - - 안쪽 action의 예방 여부가 바깥 action의 조회 결과를 오염시키지 않음 - -### 기존 검증 명령 - -1. `yarn workspace @stackflow/core test` -2. `yarn workspace @stackflow/core typecheck` -3. `yarn workspace @stackflow/core build` -4. `yarn workspace @stackflow/react typecheck` -5. `yarn workspace @stackflow/react build` -6. `yarn workspace @stackflow/plugin-blocker test` -7. `yarn lint` -8. `yarn changeset status` -9. `git diff --check` - -## 비목표 - -- [FEP-2636](https://linear.app/daangn/issue/FEP-2636)의 - `Paused → Resumed` 사이에 queued event가 없을 때 Stack이 paused에 남는 Core - reducer 결함 수정 -- `preventDefault()` 이후의 pre-effect hook 순회 중단 -- `preventDefault()`를 되돌리는 `allowDefault()` 계열 API -- plugin 등록 순서 변경 -- loader, lazy component, Suspense 또는 immediate-render 정책 변경 -- `plugin-blocker` 구현이나 테스트 변경 -- 구버전 Core 호환을 위한 capability check나 compatibility shim - -## 산출물과 커밋 경계 - -- 설계 문서: `CONTEXT.md`, - `docs/adr/0001-observe-prevented-actions-without-short-circuiting-hooks.md`, 이 계획 -- Core 공개 API와 문서 -- React loader bug fix -- Core minor + React patch changeset - -이미 존재하는 커밋은 수정하지 않는다. 구현 변경은 새 커밋으로 쌓고, 최소한 -Core API와 React bug fix의 의미 경계가 diff에서 구분되도록 한다. From e1e0cd7ab157cb7dd0fed4a8cb41973fde2e2b8a Mon Sep 17 00:00:00 2001 From: Jaewon Seo Date: Mon, 27 Jul 2026 01:35:59 +0900 Subject: [PATCH 8/8] Delete docs/adr/0001-observe-prevented-actions-without-short-circuiting-hooks.md --- ...observe-prevented-actions-without-short-circuiting-hooks.md | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 docs/adr/0001-observe-prevented-actions-without-short-circuiting-hooks.md diff --git a/docs/adr/0001-observe-prevented-actions-without-short-circuiting-hooks.md b/docs/adr/0001-observe-prevented-actions-without-short-circuiting-hooks.md deleted file mode 100644 index c8742d101..000000000 --- a/docs/adr/0001-observe-prevented-actions-without-short-circuiting-hooks.md +++ /dev/null @@ -1,3 +0,0 @@ -# Observe prevented actions without short-circuiting hooks - -Pre-effect hooks continue in plugin order after `preventDefault()` because downstream plugins may still need to observe or transform the action. Core exposes the action-local state through `actions.isPrevented()` on every pre-effect hook so a downstream plugin can skip work that is invalid for a Prevented Action; this was chosen over stopping hook traversal, which would change established plugin ordering and blocker replay behavior.