Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
2b4cbdf
feat: add @stackflow/plugin-blocker package scaffold
ENvironmentSet Mar 10, 2026
3359cff
chore(plugin-blocker): set up Jest test harness with React Testing Li…
ENvironmentSet Mar 10, 2026
7248d39
chore: add multiple new package dependencies to Yarn cache
ENvironmentSet Mar 10, 2026
0d6572f
feat(plugin-blocker): define public API types and function signatures
ENvironmentSet Mar 10, 2026
789ff4a
docs(plugin-blocker): add technical specification for blockerPlugin
ENvironmentSet Mar 10, 2026
4da8d69
add test plan
ENvironmentSet Mar 10, 2026
37341dc
test(plugin-blocker): add 1-1 기본 차단 test cases
ENvironmentSet Mar 10, 2026
89950f9
add guidelines
ENvironmentSet Mar 10, 2026
0466f8d
test(plugin-blocker): add 1-2 through 2단락 test cases
ENvironmentSet Mar 10, 2026
ba60265
add feedback
ENvironmentSet Mar 10, 2026
b3488f7
plan update
ENvironmentSet Mar 10, 2026
a23e96a
test(plugin-blocker): add 3-1 기본 bypass test cases
ENvironmentSet Mar 10, 2026
7cb3730
test(plugin-blocker): add 3-2, 3-3 bypass test cases
ENvironmentSet Mar 10, 2026
c294846
test(plugin-blocker): add 4. Composition 다중 블로커 test cases
ENvironmentSet Mar 10, 2026
60024f5
test(plugin-blocker): add 5. Lifecycle test cases
ENvironmentSet Mar 10, 2026
c32f142
refactor(plugin-blocker): replace bypass(BlockedNavigation) with over…
ENvironmentSet Mar 12, 2026
8da3b5e
update spec
ENvironmentSet Mar 12, 2026
1b4ac4b
test update
ENvironmentSet Mar 12, 2026
a46b633
interface change
ENvironmentSet Mar 13, 2026
199b356
fix test
ENvironmentSet Mar 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,009 changes: 1,006 additions & 3 deletions .pnp.cjs

Large diffs are not rendered by default.

Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
116 changes: 116 additions & 0 deletions extensions/plugin-blocker/docs/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# blockerPlugin 기술 스펙

## 1. 배경

모바일 웹뷰 기반 서비스 팀들(중고차, 부동산, UGC, 알바)이 공통적으로 "화면 이탈 방지 UX"를 구현하고 있다. 대표적으로 글쓰기 퍼널에서 뒤로가기를 누르면 "정말 나가시겠습니까?" 다이얼로그를 띄우는 패턴이다.

각 팀은 이를 독자적으로 해결했다:

| 서비스 | 훅 | 방식 |
| --- | --- | --- |
| car-client | `useBlockHistoryBack` + `useBlockLeave` | `history.block()` + 안드로이드 브릿지 |
| realty-client | `useBlockAndroidBack` + `useBlockLeave` | 안드로이드 브릿지 + `preventSwipeBack` + AppBar onClick |
| local-business-ugc | `useBlockNavigation` | `history.block()` + 안드로이드 브릿지 + iOS 브릿지 |
| jobs-client | (없음) | 라우트 설정 플래그 + 페이지별 ad-hoc 다이얼로그 |

모두 **Stackflow 바깥**(브라우저 히스토리 API, 네이티브 브릿지)에서 백버튼 이벤트를 가로채는 방식이다. 이로 인해:

1. **`connectBackButtonsPlugin` 도입 시 충돌.** 이 플러그인이 백버튼 이벤트 흐름을 변경하여 기존 `history.block()` 기반 코드와 충돌한다. 중고차 팀에서 이중 pop 문제가 발생, `useBlockLeave`가 비활성화된 상태.
2. **동일 문제를 4개 팀이 중복으로 해결.** 각각 미묘하게 다른 버그와 edge case를 안고 있다.
3. **백버튼 소스별 별도 처리.** 안드로이드 하드웨어 백, iOS 스와이프, 브라우저 히스토리, UI 백버튼 — 각각 다른 API로 제어해야 하며, 통합이 각 서비스의 책임이 되어 있다.

## 2. 문제 정의

**화면 이탈 제어라는 네비게이션 관심사가 Stackflow 바깥에서 서술되고 처리되고 있다.**

Stackflow 코어에는 `onBefore*` + `preventDefault()` 메커니즘이 존재하지만, 이는 전역 플러그인 API이다. 개별 액티비티가 "나로부터의 이탈을 막아줘"라고 선언할 수 있는 인터페이스가 없다. 이탈 차단 여부는 대부분 UI 상태(폼 dirty 여부, 업로드 진행 중 등)에 의존하는데, 전역 플러그인에서 컴포넌트 상태에 접근하려면 외부 상태 저장소 연동 등 세레모니가 필요하다.

## 3. 설계 방향

### 설계 가치

```
D. 모든 이벤트 소스가 하나의 인터셉트 지점으로 합류 ← 기반
B. 그 인터셉트 지점은 Stackflow 내부에 있음 ← 소유권
C. Stackflow는 최소한의 프리미티브로 이를 제공 ← API 철학
A. 액티비티가 그 프리미티브를 사용해 자기 정책을 선언 ← 사용 패턴
```

### 설계 결정

- **코어 변경 없음.** `onBefore*` + `preventDefault()`는 이미 충분한 capability를 제공한다. 부족한 것은 capability가 아니라 액티비티 컴포넌트에서의 접근 ergonomics이다.
- **`StackflowReactPlugin`으로 구현.** 코어 플러그인 훅(`onBeforePush`, `onBeforePop`, `onBeforeReplace`, `onBeforeStepPush`, `onBeforeStepPop`, `onBeforeStepReplace`)과 React 레이어를 결합하는 기존 패턴을 따른다.
- **Callback 기반 인터페이스.** 차단을 `onBlocked` 콜백으로 통보하고, 이후 UX 흐름은 개발자가 자유롭게 구현한다.
- **모든 네비게이션 이벤트 차단 가능.** pop뿐 아니라 push, replace, step 계열까지 `shouldBlock` predicate로 선택적 차단이 가능하다.

## 4. Public API

### `blockerPlugin`

```tsx
import type { StackflowReactPlugin } from "@stackflow/react"

declare function blockerPlugin(): StackflowReactPlugin
```

```tsx
stackflow({
plugins: [
blockerPlugin(),
// ...
],
})
```

### `useBlocker`

```tsx
type NavigationAction =
| Omit<PushedEvent, "id" | "eventDate">
| Omit<PoppedEvent, "id" | "eventDate">
| Omit<ReplacedEvent, "id" | "eventDate">
| Omit<StepPushedEvent, "id" | "eventDate">
| Omit<StepPoppedEvent, "id" | "eventDate">
| Omit<StepReplacedEvent, "id" | "eventDate">

type BlockedNavigation = { action: NavigationAction }

declare function useBlocker(options: {
shouldBlock: (action: NavigationAction) => boolean
onBlocked: (blockedNavigation: BlockedNavigation) => void
}): {
override: (fn: () => void) => void
}
```

`shouldBlock`과 `onBlocked`가 받는 값은 이벤트가 아니라 **아직 이벤트가 되기 전의 네비게이션 액션**이다. 코어의 `onBefore*` 훅 시점에는 이벤트 `id`와 `eventDate`가 아직 할당되지 않았기 때문에 이 필드들은 포함되지 않는다. 각 액션은 `name` 필드(`"Pushed"`, `"Popped"`, `"Replaced"`, `"StepPushed"`, `"StepPopped"`, `"StepReplaced"`)로 구분할 수 있다.

## 5. 시멘틱

### 차단

- `shouldBlock(action)` — 네비게이션 액션을 받아 차단 여부를 반환. `true`면 `preventDefault()`로 차단.
- 액션 발생 시점에 **commit된 render의 `shouldBlock`**이 사용된다.
- 블로커는 **activity 단위**로 동작. `isActive: true`인 activity의 블로커만 활성화.

### 통보

- `onBlocked(blockedNavigation)` — 네비게이션이 차단될 때마다 호출.
- `blockedNavigation`은 순수 데이터. `{ action: NavigationAction }`.

### override

- `override(fn)` — `useBlocker` 반환값에 포함된 함수. 콜백 `fn` 내에서 실행되는 모든 네비게이션이 이 블로커를 우회한다.
- **호출한 블로커만 우회한다.** 동일 activity의 다른 블로커는 독립적으로 동작하며, 그 블로커의 `shouldBlock`이 `true`면 차단된다.
- 임의의 네비게이션을 우회할 수 있다. 차단된 네비게이션을 재시도하는 것뿐 아니라, 완전히 다른 네비게이션도 가능하다.

### Composition

- 각 `useBlocker`는 **항상 독립적**으로 동작한다. 훅 간 암묵적 연결이 없다.
- 같은 activity에 복수 `useBlocker` 등록 가능. 경고/에러 없음.
- 차단 시 `shouldBlock`이 `true`인 **모든** 훅의 `onBlocked`가 호출된다.

### Lifecycle

- 블로커가 비활성화(unmount)되면 `shouldBlock`은 `() => false`로 간주. bypass 시 자동 통과.
- `onBlocked` 내 비동기 작업의 lifecycle 관리는 개발자 책임이다.
146 changes: 146 additions & 0 deletions extensions/plugin-blocker/docs/test-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# blockerPlugin 테스트 계획

## Context

`blockerPlugin`은 코어 변경 없이 `StackflowReactPlugin`으로 구현된 화면 이탈 방지 플러그인이다:
- `blockerPlugin()` (무인자) — 플러그인 등록
- `useBlocker({ shouldBlock, onBlocked })` — 액티비티 컴포넌트에서 차단 정책 선언

`shouldBlock`과 `onBlocked`가 받는 값은 `NavigationAction` 타입으로, 이벤트가 되기 전의 네비게이션 액션이다 (`id`, `eventDate` 미포함). 스펙의 시멘틱 섹션(§5)을 기준으로 테스트 항목을 정의한다.

## 테스트 항목

### 1. 차단 (Blocking)

#### 1-1. 기본 차단
- `shouldBlock`이 `true`를 반환하면 pop이 차단된다
- `shouldBlock`이 `true`를 반환하면 push가 차단된다
- `shouldBlock`이 `true`를 반환하면 replace가 차단된다
- `shouldBlock`이 `true`를 반환하면 stepPush가 차단된다
- `shouldBlock`이 `true`를 반환하면 stepPop이 차단된다
- `shouldBlock`이 `true`를 반환하면 stepReplace가 차단된다

#### 1-2. 기본 허용
- `shouldBlock`이 `false`를 반환하면 네비게이션이 허용된다

#### 1-3. 액션 선택적 차단
- Replaced는 차단하고 Pushed는 허용할 수 있다
- shouldBlock은 마지막으로 commit된 render에서 전달된 함수를 사용한다

#### 1-4. Activity 스코프
- 액티비티 위에 다른 액티비티가 push되면 밑에 있던 액티비티의 블로커는 비활성화된다
- 액티비티 위에 push되어있던 모든 액티비티가 pop으로 exit되면 밑에 있던 액티비티의 블로커가 다시 활성화된다
- 액티비티가 replace되면 해당 액티비티의 블로커는 비활성화된다
- 액티비티가 pop되면 해당 액티비티의 블로커는 비활성화된다

### 2. 통보 (Notification)

- 블로커가 네비게이션을 차단하면 onBlocked가 호출된다
- 차단하지 않은 블로커의 onBlocked는 호출되지 않는다
- 차단되지 않은 네비게이션에 대해서는 onBlocked가 호출되지 않는다

### 3. override

#### 3-1. 기본 override
- `override(fn)` 콜백 내 네비게이션은 블로커를 우회한다

#### 3-2. 호출 블로커만 우회
- `override`는 호출한 블로커만 우회하고, 다른 블로커가 `shouldBlock: true`이면 다시 차단된다

#### 3-3. 독립 실행
- `override`를 여러 번 호출하면 매번 독립적으로 실행된다

### 4. Composition (다중 블로커)

- 복수 블로커 등록 시, `shouldBlock`이 `true`인 모든 훅의 `onBlocked`가 호출된다
- 하나의 블로커만 `shouldBlock: true`이면 그 블로커의 `onBlocked`만 호출된다
- 하나의 블로커의 shouldBlock이라도 true를 반환하면 내비게이션이 차단된다
- 모든 블로커의 shouldBlock이 false를 반환하면 내비게이션이 허용된다

### 5. Lifecycle

- 블로커를 소유한 컴포넌트가 unmount되면 해당 블로커는 더 이상 차단 여부에 영향을 주지 않는다
- 블로커를 소유한 컴포넌트가 unmount되면 해당 블로커의 onBlocked도 더 이상 호출되지 않는다
- 블로커를 소유한 컴포넌트가 unmount되어도 해당 블로커의 `override`는 동작하되, 해당 블로커의 shouldBlock이 `false`를 반환했을 때와 동일하게 동작한다

---

## 구현 파일

- **테스트**: `extensions/plugin-blocker/src/blockerPlugin.spec.tsx`
- **플러그인**: `extensions/plugin-blocker/src/blockerPlugin.ts`
- **Export**: `extensions/plugin-blocker/src/index.ts`

## 검증 방법

```bash
cd extensions/plugin-blocker && yarn test
```

## 세부 가이드라인

> 새로운 가이드라인이 내려오면 이 단락을 업데이트하세요.

### API

- `@stackflow/react/future의 stackflow({ config, components, plugins })` 사용
- 이 플러그인은 `@stackflow/react/future` 전용
- activity 등록은 `defineConfig()` + `declare module "@stackflow/config" { interface Register }` 타입 확장

### 테스트 구조

- 매 `it` 블록마다 `stackflow()`를 새로 호출해 독립된 인스턴스 생성
- 모든 테스트를 한 파일에 모아서 관리
- `// given / // when / // then` 주석으로 가독성 확보

### 스택 상태 검증

- DOM 대신 spyPlugin으로 검증: `onInit({ actions })`에서 `getStack` 캡처
- `getStack().activities` 전체 배열 비교 (active activity의 steps만 비교하면 false positive 가능)

#### push 성공 검증 패턴

push가 성공했는지 꼼꼼히 확인하려면 세 가지를 모두 검증한다:

```tsx
const activitiesBefore = getStack().activities;
await act(async () => { actions.push("OtherActivity", {}); });
const activities = getStack().activities;
expect(activities).toHaveLength(activitiesBefore.length + 1);
expect(activities[activities.length - 1].name).toBe("OtherActivity");
expect(activities[activities.length - 1].enteredBy.name).toBe("Pushed");
```

#### pop 성공 검증 패턴

`transitionState`가 `"enter-done"` 또는 `"enter-active"`인 액티비티 수가 줄었는지 확인한다:

```tsx
const activeCountBefore = getStack().activities.filter(
(a) => a.transitionState === "enter-done" || a.transitionState === "enter-active",
).length;
await act(async () => { actions.pop(); });
const activeCountAfter = getStack().activities.filter(
(a) => a.transitionState === "enter-done" || a.transitionState === "enter-active",
).length;
expect(activeCountAfter).toBe(activeCountBefore - 1);
```

### given / when / then 규칙

- `expect`는 반드시 `// then` 블록에서만 사용한다
- setup 단계(given)와 중간 조작 단계(when)에서는 assertion을 하지 않는다
- React state setter는 `useEffect` 안에서 외부 변수에 할당해 테스트 스코프에 노출한다:

```tsx
let setSomeState!: (v: boolean) => void;
function TestActivity() {
const [state, setState] = React.useState(false);
React.useEffect(() => { setSomeState = setState; }, []);
// ...
}
```

### act 사용법

- `await act(async () => { ... })` 패턴 고정 (concurrent rendering 대응)
29 changes: 29 additions & 0 deletions extensions/plugin-blocker/esbuild.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
const { context } = require("esbuild");
const config = require("@stackflow/esbuild-config");
const pkg = require("./package.json");

const watch = process.argv.includes("--watch");
const external = Object.keys({
...pkg.dependencies,
...pkg.peerDependencies,
});

Promise.all([
context({
...config({}),
format: "cjs",
external,
}).then((ctx) =>
watch ? ctx.watch() : ctx.rebuild().then(() => ctx.dispose()),
),
context({
...config({}),
format: "esm",
outExtension: {
".js": ".mjs",
},
external,
}).then((ctx) =>
watch ? ctx.watch() : ctx.rebuild().then(() => ctx.dispose()),
),
]).catch(() => process.exit(1));
70 changes: 70 additions & 0 deletions extensions/plugin-blocker/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
{
"name": "@stackflow/plugin-blocker",
"version": "0.0.1",
"repository": {
"type": "git",
"url": "https://github.com/daangn/stackflow.git",
"directory": "extensions/plugin-blocker"
},
"license": "MIT",
"exports": {
".": {
"types": "./dist/index.d.ts",
"require": "./dist/index.js",
"import": "./dist/index.mjs"
}
},
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"files": [
"dist",
"src",
"README.md"
],
"scripts": {
"build": "yarn build:js && yarn build:dts",
"build:dts": "tsc --emitDeclarationOnly",
"build:js": "node ./esbuild.config.js",
"clean": "rimraf dist",
"dev": "yarn build:js --watch && yarn build:dts --watch",
"test": "jest",
"typecheck": "tsc --noEmit"
},
"jest": {
"testEnvironment": "jsdom",
"coveragePathIgnorePatterns": [
"index.ts"
],
"transform": {
"^.+\\.(t|j)sx?$": "@swc/jest"
}
},
"devDependencies": {
"@stackflow/config": "^1.2.2",
"@stackflow/core": "^1.3.0",
"@stackflow/esbuild-config": "^1.0.3",
"@stackflow/plugin-renderer-basic": "^1.1.13",
"@stackflow/react": "^1.12.0",
"@swc/core": "^1.6.6",
"@swc/jest": "^0.2.36",
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.3.2",
"@types/jest": "^29.5.12",
"@types/react": "^18.3.3",
"esbuild": "^0.27.3",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"rimraf": "^6.1.3",
"typescript": "^5.5.3"
},
"peerDependencies": {
"@stackflow/core": "^1.1.0-canary.0",
"@stackflow/react": "^1.3.2-canary.0"
},
"publishConfig": {
"access": "public"
}
}
Loading
Loading