Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
65 changes: 65 additions & 0 deletions .agents/worklogs/fep-2001/01-problem.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# FEP-2001 — 문제 인식 및 정의

> 이 worklog는 FEP-2001(plugin-history-sync preventDefault 지원) 작업의 인계 문서다.
> 문서 순서: 01-problem(문제) → 02-direction(방향성) → 03-solution(솔루션 기획) → 04-implementation(구현 맥락).
> 작업 산출물: PR #719 (feature/fep-2001), 커밋 6개. 상태는 04 문서 말미 참조.

## 1. 원 이슈 (Linear FEP-2001)

`plugin-history-sync`는 `preventDefault`와 합성될 수 없었다. `plugin-blocker`(액티비티 이탈 제어, FEP-1530)와 함께 쓰려면 해결이 필요했다.

### 문제 1: 브라우저 뒤로가기의 pop을 preventDefault할 수 없음

popstate 핸들러가 backward/step-backward 판정 시 `dispatchEvent("Popped"/"StepPopped")`를 직접 발행했다. `dispatchEvent`는 pre-effect 훅(`triggerPreEffectHook`)을 거치지 않으므로, 다른 플러그인이 `onBeforePop`에서 `preventDefault()`를 호출해도 효과가 없었다.

### 문제 2: 프로그래밍적 pop() 시 history desync

`onBeforePop`/`onBeforeStepPop`/`onBeforeReplace`가 **prevent 여부가 결정되기 전에** `history.back()` 틱을 비동기 큐에 등록했다. 이후 다른 플러그인이 prevent하면 스택은 불변인데 큐의 back()은 실행됨 → URL과 스택의 영구 불일치.

### 문제 3: 브라우저 앞으로가기 prevent 시 desync + pushFlag 오염

forward 판정 시 `pushFlag += 1` 후 `push()`를 호출했는데, push가 prevent되면 ① 브라우저 URL은 이미 이동했는데 스택은 불변 ② `pushFlag`는 `onPushed`에서만 차감되므로 누수되어 **다음 정상 push의 history sync가 삼켜지는 연쇄 desync**.

### 문제 4: 훅 실행 순서 의존성

pre-effect 훅은 순차 실행되며 prevent돼도 이미 실행된 훅의 부수효과는 롤백되지 않는다. plugin-history-sync가 먼저 등록되면 back() 큐잉 후 뒤늦게 prevent되는 구조.

## 2. 설계 검토에서 추가 발견된 심층 제약

이슈에 없었으나 솔루션을 결정지은 제약들:

### 재진입 (reentrancy)

core의 `dispatchEvent`는 post-effect 훅을 **동기 실행**한다. 훅 체인 중간에 누군가 push/pop을 호출하면 중첩 dispatch의 전체 훅 체인이 바깥 이벤트의 남은 훅들보다 먼저 완료된다. 결과:
- 훅 실행 시점의 `getStack()`은 자기 effect보다 **미래 상태**일 수 있다 (effect 페이로드는 스냅샷이라 안전).
- **훅 시점에 히스토리 연산을 큐잉하면 큐 순서 ≠ 이벤트 순서**가 된다. pre/post 어디에 두든 마찬가지 — 기존 코드도 back()은 pre, pushState는 post에 있어 거울상의 순서 역전 버그를 갖고 있었다.

### steps truncate와 가짜 STEP_POPPED

`makeActivityReducer`의 Popped 리듀서는 exit-done 직행 시(`skipExitActiveState`(= `pop({animate:false})`, 스와이프백), transitionDuration 경과, pause-resume) `steps`를 `[steps[0]]`로 truncate한다. 이때 `produceEffects`의 step diff가 **가짜 STEP_POPPED 효과를 N-1개 방출**한다. 따라서 effect 페이로드 기반의 "pop된 엔트리 수" 계산은 신뢰 불가.

### 기타 기존 결함 (작업 중 함께 해소)

- 방향 판정이 16진 id의 **사전순 비교**라 자릿수 경계에서 오판 가능.
- 멀티 엔트리 점프(`go(-n)`, 히스토리 길게 누르기)에 popstate 1회 → Popped 1회만 발행되어 여러 액티비티를 건너뛰면 미수렴.
- 플러그인이 history 리스너를 해제하지 않는 누수.

## 3. 후속 사이클에서 추가된 문제 2건 (같은 PR에서 해결)

본 사이클 리뷰 과정과 최종 브리핑에서 식별되어, 메인테이너 결정으로 PR #719에 포함:

### Obs-1: 리로드 경계 너머 backward 복원이 prevent되면 관찰-only 엔트리 재작성

리로드 시 플러그인은 현재 엔트리의 state만으로 스택을 복원한다(중간 스텝 미복원, 예: X[s0,c] — 물리 히스토리는 [s0, b, c]). desired↔인덱스 매핑은 "s0가 k-1에 있다"는 **낙관적 허구**로 부팅되며, 평소엔 unknown 보호(미지 엔트리 비재작성)와 복원 성공 시 anchor 재조정으로 무해하다. 그러나 ① back popstate로 b가 **관찰**되어 known이 되면 보호가 풀리고 ② blocker가 stepPop을 prevent하면 anchor가 허구에 고착되어 ③ reconcile이 b를 s0로 **재작성** → 이전 세션 스텝 엔트리 영구 소실(이후 back이 b를 건너뜀 — back granularity 손실). 안정 desync는 아니지만 복원 타깃 파괴.

### 멀티 엔트리 점프의 unknown 영역 한계

History API는 go(±n)에서 착지 엔트리의 state만 제공한다(중간 엔트리 정보 없음). 본 사이클 엔진은 **이번 세션 기록 범위**의 점프를 정확히 수렴시키지만, unknown(리로드 이전 기록) 엔트리가 경로에 끼면 backward는 착지 스냅샷만 재생, forward는 unknown 중간을 낙관 skip → 수렴은 유지되나 **스택 충실도**(중간 액티비티/스텝 체인)가 손실된다. 두 문제의 공통 뿌리는 "리로드 후 모델이 자기 히스토리를 모른다"이다.

## 4. 문제의 분류 (수용 기준의 근거)

1. **합성 불가 부류**: 브라우저 발 내비게이션이 플러그인 파이프라인(onBefore*/preventDefault)을 우회 — 문제 1.
2. **안정 desync 부류**: 지원되는 내비게이션(액션 호출, back/forward 버튼)으로 도달 가능한, settle 후에도 지속되는 URL↔스택 불일치 — 문제 2, 3, replace-shrink(리뷰 중 발견), 좀비 forward 가지(리뷰 중 발견).
3. **충실도 부류**: 수렴은 하나 스택/히스토리의 정보가 손실 — Obs-1, 멀티 점프 한계.

수용 기준은 이 분류를 따른다: 1·2는 구조적으로 불가능해야 하고, 3은 저널 지식이 있는 한 복원되며 없으면 우아하게 저하되어야 한다.
57 changes: 57 additions & 0 deletions .agents/worklogs/fep-2001/02-direction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# FEP-2001 — 방향성 설정

## 1. 기각된 접근과 기각 사유

### (a) 델타 패치 접근 — 기각

최초 제안: history 부수효과를 onBefore* → post-effect 훅으로 이동 + core 액션이 `{ isPrevented }` 반환 + POPPED effect에 pop 직전 스냅샷(`prevActivity`) 추가 + prevent 시 entryIndex 기반 `history.go(delta)` 복원.

기각 사유 (검토 중 단계적으로 드러남):
1. **steps truncate**: exit-done 직행 시 steps가 `[steps[0]]`로 잘려 post 훅의 effect에서 popCount를 계산할 수 없음 (`pop({animate:false})`·스와이프백 등 흔한 경로). prevActivity 스냅샷으로 보완 가능하지만 —
2. **재진입이 치명타**: post 훅 동기 실행 하에서 훅 시점 큐잉은 큐 순서 ≠ 이벤트 순서를 보장할 수 없다 (pop 처리 중 다른 플러그인이 push하면 [pushState(B), back×N]으로 역전). pre/post 어느 쪽에 둬도 거울상 버그. "이벤트별 명령형 델타를 훅 시점에 큐잉"하는 패러다임 자체가 재진입 하에서 성립 불가.

### (b) effect 버퍼링 접근 — 기각

델타를 유지하되 effect를 버퍼에 모아 이벤트 순서로 드레인. 순서 문제는 해결되지만 pushFlag/silentFlag 류 플래그 기계와 prevent 복원 로직이 전부 존속 — 이슈의 목표가 "안정화"인데 버그가 자라던 토양(플래그 동기화)을 남김.

### (c) 채택: reconciliation 패러다임

브라우저 히스토리를 React의 DOM처럼 **렌더 타깃**으로 취급. 이벤트→연산 번역을 버리고, "스택이 요구하는 히스토리 모습(desired)"과 "실제 브라우저 히스토리 모델(actual)"을 비동기 직렬 큐에서 수렴시킨다. 효과:
- 훅에서 히스토리 연산 자체가 사라짐 → 재진입·순서·플래그 문제가 **설계상 비존재**.
- reconcile은 드레인 시점의 최신 스택만 읽음 → 중간 상태 자연 붕괴.
- prevent 복원이 별도 로직이 아니라 수렴의 자연 결과 (스택 불변 → browser↔stack 차이 감지 → 자동 복원).
- popCount 계산 자체가 소멸 (desired에 없는 엔트리는 actual 모델이 제거량을 앎).

## 2. 결정 원칙 (이후 모든 판정의 기준)

1. **core 무변경**: 이슈 범위에서 `@stackflow/core`는 건드리지 않는다. 코어 동작(재진입, truncate, 슬롯 순서 등)은 제약으로 수용하고 플러그인 쪽에서 해결. 코어 개선(중첩 dispatch 큐잉 등)은 별도 이슈 후보.
2. **공개 API 불변 + 캡슐화**: historySyncPlugin 옵션(routes/config, fallbackActivity, useHash, history, urlPatternOptions), HistoryQueueContext 계약(requestHistoryTick), SSR/defaultHistory staged setup 경로, 부팅 UX(리로드 후 현재 액티비티만 복원) 전부 보존. 새 내부 모듈은 비export.
3. **판정은 관찰 가능한 종단 결과 기준**: "레거시도 그랬다(패리티)"는 엔트리 잔존 같은 중간 상태가 아니라 **사용자가 관찰하는 최종 결과**(URL↔화면)까지 추적해야 성립한다. 본 사이클 리뷰에서 이 원칙으로 replace-shrink 결함의 차단 여부를 중재했다 (레거시는 같은 입력에서 기이하게나마 수렴했으므로 새 엔진의 안정 desync는 회귀).
4. **수용 기준 = "지원 내비게이션으로 도달 가능한 안정 desync의 제거"**: forward/back 버튼·go(n)·액션 호출·blocker prevent/proceed 조합으로 도달 가능한 모든 상태에서 settle 후 URL↔스택 일치.
5. **낙관 보존 불변**: 모델이 모르는(이전 세션) 엔트리는 복원 타깃이므로 절대 덮어쓰지 않는다. 후속 사이클에서 "관찰되었다고 보호가 풀리지 않는다"로 강화됨 (재작성 자격 = 이번 세션이 직접 기록한 엔트리).
6. **예외는 3분류**: expected(값/플래그로 처리 — prevent, 미지 엔트리, out-of-app, pause, 저널 실패), unexpected(`HistorySyncDesyncError` — 진단 + 1회 resync + give-up, 삼킴 금지), teardown(`ReconcilerSuspendedError` — 무음 정상 경로).

## 3. 프로세스 방향 (협업 구조)

메인테이너 지시로 3단계 × 다중 세션 구조 채택. 각 단계 산출물은 커밋 1개.

1. **테스트 클랜징**: 리뷰 세션 2개(Claude/Codex)가 독립 전수 리뷰 → 판정 불일치는 1라운드 토론으로 수렴 → 오케스트레이터 확정 → 작업자가 정리.
2. **테스트 작성 (TDD)**: 작성자(Codex) ↔ 리뷰어(Claude) 루프. 목표 동작은 `it.failing`으로 스펙화해 suite를 green으로 유지하고, 구현 단계에서 해제하는 것이 수용 기준. 리뷰어는 **flip 실험**(failing→it 치환 후 실패 지점 확인)으로 "올바른 이유로 red인지"를 전수 검증 — 가짜 red(셋업 오류로 죽는 테스트)와 green 불가능한 거짓 타깃을 차단.
3. **구현**: 작성자(Claude) ↔ 이중 리뷰(Claude + Codex), 양측 APPROVE까지 루프, 충돌은 오케스트레이터 중재. 리뷰는 **probe 실증주의**: 차단 판정은 재현 probe를 동반하고, 승인된 probe는 회귀 테스트로 고정.

### 구속력 정의

- plugin-blocker 스펙 §8(proceed = 신규 디스패치로 전체 파이프라인 재통과) + §7-1(무재진입)은 **구속력 있는 생태계 계약** — FEP-2001 합성의 전제.
- §7-2(블로커 간 세부 알림 순서)는 advisory.
- 기존 historySyncPlugin.spec.ts의 단언(URL, entry 수, 스택 end-state)은 보존 대상 계약. `history.index` 단언은 "entry 수 = back 가능 횟수"로 관찰 가능하므로 유지.

## 4. 후속 사이클 방향 (Obs-1 + 멀티 점프)

두 문제의 공통 뿌리("리로드 후 모델이 자기 히스토리를 모름")에 대해 **HistoryEntryJournal**(sessionStorage 영속화)을 도입하되, 검증된 reconciler 불변을 보존하는 **보수적 통합**을 채택:

- 부팅 시 **모델만** 저널로 풀 시드. 스택 복원(overrideInitialEvents)은 현행 유지 — 전체 스택 부팅 복원 대안은 리로드 시 하위 액티비티 로더/마운트가 전부 실행되는 UX 변화라 기각.
- anchor 연속성 불변과 낙관적 부팅 허구는 유지하고, 대신 **재작성 자격을 provenance(session-write)로 축소**해 허구를 무해화 — Obs-1은 저널 유무와 무관하게(폴백 모드 포함) 해결되어야 함.
- 멀티 점프는 저널 스냅샷의 **역사적 재생**(원본 id/eventDate 보존 재디스패치 — 기존 복원 메커니즘의 확장)으로 체인 충실도 복원.
- 저널 실패는 expected → 진단 동반 폴백. 폴백 모드는 기존 동작으로 자연 축퇴.

프로세스는 동일하되 1단계(클랜징) 생략, 리뷰어 추가 포커스: 2단계 "이슈를 테스트로 온전히 표현했는가", 3단계 "이슈를 온전히 해결했는가"(모드 한정/부분 수정 검출).
Loading
Loading