diff --git a/.agents/worklogs/fep-2001/01-problem.md b/.agents/worklogs/fep-2001/01-problem.md new file mode 100644 index 000000000..1438fb25c --- /dev/null +++ b/.agents/worklogs/fep-2001/01-problem.md @@ -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은 저널 지식이 있는 한 복원되며 없으면 우아하게 저하되어야 한다. diff --git a/.agents/worklogs/fep-2001/02-direction.md b/.agents/worklogs/fep-2001/02-direction.md new file mode 100644 index 000000000..400daf744 --- /dev/null +++ b/.agents/worklogs/fep-2001/02-direction.md @@ -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단계 "이슈를 온전히 해결했는가"(모드 한정/부분 수정 검출). diff --git a/.agents/worklogs/fep-2001/03-solution.md b/.agents/worklogs/fep-2001/03-solution.md new file mode 100644 index 000000000..0d76cf3ee --- /dev/null +++ b/.agents/worklogs/fep-2001/03-solution.md @@ -0,0 +1,44 @@ +# FEP-2001 — 솔루션 기획 + +## 1. 본 사이클: reconciliation 엔진 + +### 구성 요소 + +1. **Desired entries** — reconcile 시점의 최신 스택에서 계산: entered(enter-active/enter-done) 액티비티들을 enteredBy.eventDate 순으로 정렬해 live steps를 펼친 엔트리 리스트 `[{identity: {activityId, stepId}, path, state}]`. 첫 스텝 엔트리는 step 필드를 생략해 레거시 state 모양을 유지. enter-active Replaced 액티비티의 직전 생존자 drop 보정 포함(victim이 transition 완료 전 미마킹인 코어 동작의 선반영 — 단, **새 슬롯을 만든 Replaced에 한정**: in-place replace(기존 activityId 재사용)는 코어가 victim을 영원히 마킹하지 않으므로 drop 미적용). +2. **Actual model** (`BrowserHistoryEntryModel`) — 절대 entryIndex 좌표계의 부분적(partial) 브라우저 히스토리 모델: knownEntries Map(identity + state + 기록 path), currentIndex, topIndex(존재를 믿는 최고 인덱스), anchorIndex(desired[0]의 절대 인덱스), outOfApp 플래그. entryIndex는 history state(flatted 직렬화 내부)에 영속 — 구버전 state와 양방향 호환. +3. **Reconciler** (`HistoryReconciler`) — Mutex 직렬 큐 + **1-op-per-iteration 수렴 루프**: 매 반복 desired/actual을 재계산하고 연산(go/pushState/replaceState) 1개만 실행 → 패스 도중 끼어드는 popstate/재진입 디스패치를 다음 반복이 흡수. 자기 유발 popstate는 **expectation**(go 발행 전 등록, 직렬 큐라 깊이 1)으로 식별 — silentFlag 대체. requestHistoryTick 어댑터로 기존 컨텍스트 계약 유지. +4. **popstate → 정식 액션 번역** — 방향 판정은 entryIndex 비교(레거시 state만 id 비교 폴백). backward(타깃 entered)는 활동 단위 `actions.pop()` 루프 + 동일 활동 내 `stepPop()` 루프 — **전부 pre-hook(blocker preventDefault) 통과**. prevent 감지는 이벤트 로그 증가분의 name 검사(코어 무변경 제약 하 유일한 동기 판정 수단). backward(타깃 미지·리로드 후)는 pop(preventable) + 스냅샷 역사적 재생(dispatchEvent — 사용자 내비게이션이 아닌 상태 재구축이므로 의도적으로 pre-hook 미통과). forward는 known 중간 엔트리 순차 재생(각각 preventable), unknown은 낙관 skip. **처리 후 무조건 requestReconcile** → prevent 시 스택 불변이므로 reconciler가 브라우저를 자동 복원. +5. **트리거** — onChanged + popstate 처리 직후. onBefore*는 path 채우기(순수 param override)만. pushFlag/silentFlag/onPushed/onStepPushed/onReplaced/onStepReplaced/onBeforePop/onBeforeStepPop 전부 제거. + +### 정렬(alignment)·절단 규칙 + +- 불변식: desired D(m개)는 절대 인덱스 [anchor, anchor+m-1]을 점유, 수렴 후 커서 = anchor+m-1. +- 미지 엔트리는 낙관적 일치로 간주하고 절대 재작성하지 않음(이전 세션 복원 타깃 보존). anchor 미만(외부 엔트리)은 불가침. anchor는 backward 복원 성공 시에만 `착지인덱스 - (m_new - 1)`로 하향 확장. +- divergence(known 엔트리의 identity ∪ 기록-path 불일치) 처리 3분기: 커서 > 분기점 → go 후진 + replaceState(위쪽이 여전히 desired인 케이스의 per-entry 수선) / 커서 == 분기점 → in-place replaceState(실브라우저 의미론) / 커서 < 분기점(죽은 forward 가지) → go(분기점-1) + **pushState 재구축으로 가지 절단**. +- 절단 강화: divergence가 desired **마지막** 엔트리이고 그 위에 이번 세션 기록 엔트리가 잔존하면(replace-shrink) push 재구축으로 stale suffix 절단. 루트(anchor)는 push 발판이 없어 in-place 유지(레거시 패리티, 문서화된 한계). +- 예외 3분류는 02-direction §2-6 참조. unexpected는 go 타임아웃(10s)/수렴 상한(100회)/out-of-range go 사전 차단. + +### 수용 기준 (2단계 산출, blocker.spec) + +결정적 harness(고정 sleep 금지): isolated window shim(pushState forward-truncate/popstate 비동기 발화 등 실브라우저 의미론), settle = 17ms/샘플 × 연속 2회 안정(core interval 16.67ms 초과) + 상한 60회 throw, 실제 `stackflow()` 사용, 단언은 관찰 가능 계약만(URL, entry delta, active params, 공개 콜백 — history.index/내부 flag/큐잉 순서 금지). + +`it.failing`으로 스펙화 후 구현에서 해제(9건): 브라우저 back의 blocker 경유+복원, proceed replay 동기화, rapid back 수렴, step back 복원, 차단된 pop/stepPop 무desync, 프로그래밍 proceed, 재진입(onBlocked 내 push), go(n) 멀티 점프. 현 엔진 충족분은 일반 it(차단 push/replace/step* 무desync, back/forward 수렴, pause/resume 일괄 수렴, fallbackActivity 실경로 1회, C군). 리뷰 probe는 회귀 테스트로 고정(좀비 forward 절단, in-place replace 루트 보존, replace-shrink 절단). + +## 2. 후속 사이클: HistoryEntryJournal (확정 설계 §1~6) + +1. **저널**: 이번 탭 세션에서 이 앱이 기록한 엔트리들(entryIndex, 식별자, enteredBy 스냅샷 포함 state, canonical path)의 영속 기록. sessionStorage(탭 단위 — 히스토리와 수명 일치), historyState와 동일한 flatted 직렬화, 단일 키 전량 덮어쓰기(부분 상태 불가). 어댑터 패턴: memory history/SSR/스토리지 불가 → no-op. +2. **부팅**: 저널을 현재 엔트리와 검증(버전 + 현재 entryIndex의 저널 기록 식별자 ↔ location.state 식별자) → 유효하면 **모델만** journal-known으로 풀 시드. 스택 복원은 현행 유지(부팅 UX 무변경). 무효/부재/예외 → reset + 현행 낙관 부팅 폴백. +3. **재작성 자격 축소 (Obs-1 구조적 해결)**: identity-divergence에 의한 재작성/절단 자격을 **provenance === "session-write"**(이번 세션이 직접 기록)로 한정. journal-known·관찰-only(observation) 엔트리는 복원 타깃으로 보호 — **관찰되어도 보호가 풀리지 않음**. Adv-1 규칙(현재 엔트리 ∧ enteredBy.id 불일치 시 refresh)은 유지. 부팅 낙관 허구는 그대로 두되 보호 확대로 무해화. **저널 없는 폴백 모드에서도 관찰-only 보호가 동작**(양 모드 해결). +4. **멀티 점프 체인 재구성**: backward 점프로 journal-known 영역 착지 시 착지 이하 연속 known 체인을 역사적 재생(+ entered 활동 수만큼 formal pop — preventable). forward 점프는 기존 known-중간 재생 경로가 저널 지식으로 자연 확장. 폴백 모드는 착지 엔트리 1건으로 자연 축퇴(기존 동작 + 보호). +5. **예외 정책**: 저널 read/write/quota/SecurityError/검증 불일치 = expected → 1회성 진단 + 폴백. 저널 호출은 전부 내부 try/catch로 reconcile 경로와 격리. 콜드 스타트(부재)는 정상이므로 무진단. +6. **경계**: 듀얼 인스턴스(동일 history 위 두 stackflow)에서 저널 충돌은 부팅 검증 실패 → 폴백이 안전망. 무관 키 비간섭(reset은 자기 키만). 다른 코드의 히스토리 조작은 계약 밖 — 검증 실패 → 폴백. + +### 수용 기준 (후속 2단계 산출) + +harness 확장: sessionStorage shim(연산 fault 주입 + 부재 + **프로퍼티 접근 자체 throw**(SecurityError)) + `reloadHarness()`(동일 history/storage 위 인스턴스 재생성). `it.failing` 4건: A1(journal 모드 Obs-1 — prevent 후 granularity 보존), A2(폴백 모드 동일 — 저널 없이 보호만으로), B1(cross-reload backward go(-n) 체인 충실도 — activityCount + pop 도달성), B2(cross-reload forward go(+n) 체인 재구성). 일반 it: backward/forward × prevent 복원, 듀얼 인스턴스 × 스토리지, 저널 라이프사이클 4종(무관 데이터/부재/연산 throw/접근 throw). + +### 의도적으로 풀지 않은 것 (문서화된 한계) + +- 루트 엔트리 in-place 재작성의 절단 불가(레거시 패리티). +- 저널 지식 범위 밖(스토리지 클리어 등) 점프는 착지 스냅샷 복원으로 우아한 저하. +- 사용자가 과거 activityId로 직접 push/비활성 활동 in-place replace하는 케이스는 계약 밖(ordering note에 명시). diff --git a/.agents/worklogs/fep-2001/04-implementation.md b/.agents/worklogs/fep-2001/04-implementation.md new file mode 100644 index 000000000..015bbb692 --- /dev/null +++ b/.agents/worklogs/fep-2001/04-implementation.md @@ -0,0 +1,89 @@ +# FEP-2001 — 구현 맥락 + +> 이어서 작업하는 에이전트는 이 문서를 코드와 함께 읽을 것. /tmp의 세션 산출물은 소실되므로 결정 로그(D/FD)는 여기에 전문 수록했다. + +## 1. 모듈 지도 (extensions/plugin-history-sync/src — 신규 모듈 전부 index.ts 비export) + +| 모듈 | 역할 | +|---|---| +| `desiredHistoryEntries.ts` | 스택 → 기대 엔트리 계산 (entered 활동 × live steps, eventDate 정렬, Replaced victim drop — in-place replace 제외 판별 포함, ordering note 주석) | +| `BrowserHistoryEntryModel.ts` | actual 모델: knownEntries(identity/state/path/**provenance**), currentIndex/topIndex/anchorIndex/outOfApp, push 기록 시 forward truncate, `restoreJournalEntry`(topIndex 비상승), `hasWrittenEntriesAbove`(session-write 한정) | +| `HistoryReconciler.ts` | Mutex 직렬 큐 + 1-op-per-iteration 수렴, expectation 기반 자기유발 popstate 식별, planNextOp(divergence 3분기 + stale-suffix 절단 + Adv-1), initializeFreshBoot/initializeRestored(저널 검증·시드), retain/release 수명주기, HistorySyncDesyncError/ReconcilerSuspendedError | +| `HistoryEntryJournal.ts` | sessionStorage 어댑터: flatted 단일 키 페이로드 `{version:1, entries:[[entryIndex,{state,path}]]}`, loadValidated/recordWrite(truncateAbove)/reset, 전 호출 내부 try/catch, warn 1회 | +| `historyState.ts` | State에 `entryIndex?` 추가(flatted 내부 — 구버전 양방향 호환), getStateStepId | +| `historySyncPlugin.tsx` | popstate→정식 액션 번역(체인 재생 포함), onChanged→requestReconcile(+defaultHistory staged setup 유지), onBeforePush/Replace는 path 채우기만, wrapStack retain/release | + +보존된 기존 경로: overrideInitialEvents(리로드 복원·defaultHistory SerialNavigationProcess), ActivityActivationMonitor, RoutesProvider, HistoryQueueProvider(requestHistoryTick 계약), useHash. + +## 2. 코어 동작 확정 사실 (구현의 전제 — 코어를 바꾸면 여기부터 재검토) + +- `makeEvent(name, params)`: params에 id/eventDate가 있으면 **원본 보존** → 과거 enteredBy 스냅샷 spread 재디스패치 시 aggregate의 eventDate 정렬로 **역사적 위치에 삽입**, uniqBy(id)로 중복 제거. 복원 메커니즘의 근간. +- `aggregate`: eventDate 정렬 후 리듀스 — 디스패치 순서가 아니라 eventDate가 최종 상태 결정. +- **isActive는 eventDate가 아니라 activities 배열 슬롯 순서**의 마지막 entered (`aggregate.ts` 후처리). `findNewActivityIndex`는 같은 activityId 재push 시 기존 슬롯 재사용 → 슬롯 순서 ≡ 이벤트 날짜 순서 불변이 깨질 수 있음(FD9의 근거). +- Popped: 비exited 활동 1개뿐이면 no-op이지만 **이벤트는 기록됨**(prevented 감지·복원 정합에 활용). exit-done 직행 시 steps truncate + 가짜 STEP_POPPED. +- StepPopped: steps.length > 1일 때만 실제 제거. no-op이어도 이벤트 기록. +- `makeActivityFromEvent`: steps[0].id === activityId (첫 엔트리 stepId = activityId — identity 연속성). +- Replaced: victim은 replacer가 enter-done 전환 시점에 마킹. **in-place replace(기존 activityId 재사용, findTargetActivityIndices의 alreadyExisting 분기)는 victim을 영원히 미마킹**. +- withPauseReducer: pause 중 디스패치는 pausedEvents로 미뤄짐(stack.events 미기록) → prevented 감지가 pause 중엔 '막힘' 판정 — 의도된 동작(스택 동결 → reconcile이 원위치 복원). +- 코어 액션은 prevent 여부를 반환하지 않음 → **디스패치 성공 감지 = getStack().events 증가분의 name 검사** (dispatchChecked). +- react 통합: store.init()은 브라우저에서만 → onInit의 동기 초기 기록은 SSR 안전. + +## 3. 결정 로그 — 본 사이클 (D1~D10) + +- **D1**: 방향 판정을 entryIndex 비교로 (구 16진 id 사전순 비교는 자릿수 경계 버그). 레거시 state만 id 비교 폴백(±1 추정). +- **D2**: backward 복원 = actions.pop()(blocker 통과) + dispatchEvent(역사적 push). 복원 push는 상태 재구축이므로 의도적으로 pre-hook 미통과. +- **D3**: prevented 감지 = events 증가분의 이벤트 name 검사 (코어 무변경 하 유일한 동기 판정). +- **D4**: unknown 엔트리는 낙관적 일치 — 절대 재작성 금지(이전 세션 복원 타깃 보존). +- **D5**: enter-active Replaced victim drop 보정 (transition 중 신구 동시 포함 방지). +- **D6**: wrapStack effect 기반 retain/release dispose (리스너 누수 수정, StrictMode 안전 마이크로태스크 지연). +- **D7**: pause 중 브라우저 백 → 스택 동결 → reconcile 원위치 복원. +- **D8**: pushFlag/silentFlag/onPushed/onStepPushed/onReplaced/onStepReplaced/onBeforePop/onBeforeStepPop 전부 제거. 히스토리 기록은 오직 reconcile 경로. +- **D9**: victim-drop 일반화 — "그 Replaced 이벤트로 exitedBy 마킹된 활동이 아직 없으면" drop (transition interval 의존 flaky 1건을 사전 트레이스로 발견·해결). 라운드 2에서 "새 슬롯을 만든 Replaced에 한정"(이벤트 로그에서 같은 activityId 선행 진입 이벤트 부재)으로 재한정 — in-place replace 오발동(루트 파괴) 방지. +- **D10**: 기존 spec.ts 27건 이관 0건 — 48ms makeActionsProxy harness가 새 엔진과 비충돌(듀얼 인스턴스 새로고침 테스트 포함). + +리뷰에서 추가된 수정: planNextOp 3분기(커서 vs 분기점 — 전진-후-재작성 경로 제거), KnownHistoryEntry.path(in-place replace의 URL 갱신 누락 해결 — 관찰-only는 path null로 비재작성 불변과 양립), replace-shrink stale suffix 절단(hasWrittenEntriesAbove), restored-step liveSteps 가드, retain 시 requestReconcile, MAX_NAVIGATION_DISPATCHES 소진 진단. + +## 4. 결정 로그 — 후속 사이클 (FD1~FD10) + +- **FD1**: 저널 페이로드에 anchor 필드 생략 (검증은 버전+현재 엔트리 식별자만 사용, 소비자 없음). +- **FD2**: 저널 시드는 knownEntries만, **topIndex는 currentIndex 캡** — 지식(복원용)과 존재 믿음(append 판정)의 분리. 리로드 직후 push가 이전 세션 forward 가지를 구버전처럼 pushState로 절단하는 의미론 보존. 물리 확인은 popstate 도착 시 learnEntry가 상승. +- **FD3**: Adv-1 조건을 path===null → provenance!=="session-write"로 일반화. 부팅·복원 디스패치는 원본 enteredBy.id 보존이라 비발동 보증 유지. +- **FD4**: lost-step 복원의 stepsToPop을 liveSteps-1로 클램프 (낙관 허구 거리의 과대 계산 보정 — 폴백 모드 멀티-back이 bounce trap 대신 정상 복원). +- **FD5**: 저널 인메모리 Map 진실원본 + persist 전량 덮어쓰기 → 실패는 "동결된 일관 스냅샷"만 가능, 다음 부팅 검증이 중재. 실패해도 비활성화하지 않음(일시 quota 자기치유). +- **FD6**: backward 비entered 분기 pop 수 = 현재 entered 활동 수 (기존 1회 고정의 멀티 점프 좀비 잔존 해소). 체인 dates < 세션 dates → Popped(now) k건이 정확히 세션 활동 k개 exit. +- **FD7**: 메모리 히스토리 감지 = `"index" in history` (history v5 MemoryHistory 전용 필드, 실측) → spec.ts 전 구간 저널 no-op. +- **FD8**: forward 체인 재생은 기존 fresh re-push 메커니즘 유지 (저널 지식으로 자연 확장 — 검증된 경로 보존). +- **FD9 (중요)**: backward 체인 재생 시 **forward 영역의 known 활동 슬롯도 구체화** — 역사적 Pushed + 즉시 skipExitActiveState Popped(dispatchEvent, 비preventable 상태 재구축). 근거: isActive의 슬롯 순서 의존(§2) 때문에, 구체화 없이 forward 재생하면 중간 활동이 새 슬롯 append + 착지 활동이 옛 슬롯 재사용 → 잘못된 활동이 active (B2 1차 red로 실증). 역사적 Pushed는 enter-done(과거 날짜), 합성 Popped는 exit-done — 렌더 플래시 없음. 재생 후 active==착지 가드. +- **FD10 (중요)**: Adv-1 divergence는 **in-place replaceState로만 해소** — 절단 경로(go 수반)로 보내면 index===currentIndex 조건이 커서 이동 순간 소멸해 go↔go 무한 진동 (B2 settle 실패로 실증). 절단 자격은 session-write divergence 한정. + +## 5. 검증 상태 (HEAD = ad078638 기준) + +- plugin-history-sync: 8 suites / **80 tests green** — blocker.spec 28(수용 스펙 + 회귀 3 + 후속 수용 4 해제분 + prevent/듀얼/스토리지), spec.ts 29(복구 2 포함, 48ms harness 그대로), react.spec 4(SSR/hydration), 유닛 4파일 19 +- plugin-blocker: 37 green (§8/§7-1 구속 계약 포함). typecheck/biome(기존 warning 3건 외 무결)/build green +- 테스트 실행: `yarn workspace @stackflow/plugin-history-sync jest --watchman=false` (Watchman 소켓 권한 오류 환경 대응) + +## 6. 커밋 맵 (feature/fep-2001, PR #719 — draft) + +| 커밋 | 내용 | +|---|---| +| `2c1253c0` | 테스트 클랜징 (동어반복 fallbackActivity 테스트 + relay loadRef 테스트 제거 — **ad078638에서 메인테이너 요청으로 복구됨**) | +| `29200573` | 결정적 harness + 수용 스펙 14건 (it.failing 8) | +| `380ed94c` | reconciliation 엔진 (+1770/−614, failing 9건 해제 + 회귀 3건) | +| `6750b448` | 후속 수용 스펙 (sessionStorage/reload harness + it.failing 4) | +| `56a78723` | HistoryEntryJournal + provenance 보호 + 체인 재생 (failing 4건 해제) | +| `ad078638` | 테스트 2건 복구 (relay fixtures/devDeps 포함, 새 엔진에서 통과 확인) | + +## 7. 리뷰 이력 요약 (재발 방지 참고) + +- 본 사이클 3라운드: R1 Claude가 probe로 Major 2건(좀비 forward 가지·전진-후-재작성 전략, in-place replace victim-drop 오발동→루트 파괴) → R2 Codex가 인접 케이스에서 신규 Major(replace-shrink stale suffix 보존→안정 desync), Claude APPROVE와 충돌 → 오케스트레이터가 "관찰 가능 종단 결과 기준 회귀"로 차단 중재 → R3 양측 APPROVE. 모든 차단 probe는 회귀 테스트로 고정됨. +- 후속 사이클: 2단계 2라운드(가짜 red/green 불가 타깃/변형 누락 교정), 3단계 1라운드 양측 APPROVE (Claude P3/P5 probe로 "올바른 메커니즘" 분리 실증 — Obs-1은 저널 비의존 보호로, 충실도는 저널 지식으로). +- 교훈: ① it.failing 스펙은 flip 실험으로 "올바른 이유의 red + 구현 후 green 가능"을 검증할 것 ② "레거시 패리티" 논거는 종단 결과까지 추적할 것 ③ 인접 변종(replace-shrink 같은)을 probe로 칠 것. + +## 8. 알려진 한계 / 열린 항목 + +- **루트 엔트리 in-place 재작성 절단 불가** (push 발판 부재) — 레거시 패리티, 주석 문서화. 루트 활동에 스텝을 쌓고 replace 후 forward하는 초엣지에서 desync 가능(레거시도 동일 입력에서 미수렴). +- **저널 지식 범위**: 탭 단위(sessionStorage), 이 앱이 기록한 엔트리만. 범위 밖 점프는 착지 스냅샷 복원으로 저하. +- 계약 밖: 외부 코드의 동일 History 조작, 과거 activityId 직접 push, 비활성 활동 in-place replace (ordering note 참조). +- 비차단 관찰(후속 리뷰 O1~O4): Adv-1 refresh 시 보호 엔트리의 session-write 전환(무해 확인), FD9 합성 이벤트의 플러그인 가시성(D2 동급), forward 가지 존재 시 formal pop의 exit 귀속이 로그상 코스메틱 어긋남(최종 상태 정확), warnOnce가 진단 범주 통합 1회. +- 선택 제안(O6): 점프 경로에 스텝 엔트리가 끼는 변형(probe P4 green)의 정식 스펙 승격. +- 남은 작업 후보: changeset 작성(release용 — 아직 없음), PR ready 전환, 코어 개선 별도 이슈(중첩 dispatch 큐잉, 재진입 계약 문서화). diff --git a/.pnp.cjs b/.pnp.cjs index 6a194ab38..f141b9046 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -103,7 +103,7 @@ const RAW_RUNTIME_STATE = ["@stackflow/link", ["virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/link", "workspace:extensions/link"]],\ ["@stackflow/monorepo", ["workspace:."]],\ ["@stackflow/plugin-basic-ui", ["virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/plugin-basic-ui", "workspace:extensions/plugin-basic-ui"]],\ - ["@stackflow/plugin-blocker", ["workspace:extensions/plugin-blocker"]],\ + ["@stackflow/plugin-blocker", ["virtual:991015ceb8acca106af7e64cf676369bf8fb98370003b1af0559fb22931c330c3a09d064107412d6cc26ef286f0afdd26340443bd43177eeda3558644ba5f206#workspace:extensions/plugin-blocker", "workspace:extensions/plugin-blocker"]],\ ["@stackflow/plugin-devtools", ["virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/plugin-devtools", "workspace:extensions/plugin-devtools"]],\ ["@stackflow/plugin-google-analytics-4", ["workspace:extensions/plugin-google-analytics-4"]],\ ["@stackflow/plugin-history-sync", ["virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/plugin-history-sync", "workspace:extensions/plugin-history-sync"]],\ @@ -6767,6 +6767,41 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["@stackflow/plugin-blocker", [\ + ["virtual:991015ceb8acca106af7e64cf676369bf8fb98370003b1af0559fb22931c330c3a09d064107412d6cc26ef286f0afdd26340443bd43177eeda3558644ba5f206#workspace:extensions/plugin-blocker", {\ + "packageLocation": "./.yarn/__virtual__/@stackflow-plugin-blocker-virtual-7955ffc616/1/extensions/plugin-blocker/",\ + "packageDependencies": [\ + ["@stackflow/plugin-blocker", "virtual:991015ceb8acca106af7e64cf676369bf8fb98370003b1af0559fb22931c330c3a09d064107412d6cc26ef286f0afdd26340443bd43177eeda3558644ba5f206#workspace:extensions/plugin-blocker"],\ + ["@stackflow/config", "workspace:config"],\ + ["@stackflow/core", "workspace:core"],\ + ["@stackflow/esbuild-config", "workspace:packages/esbuild-config"],\ + ["@stackflow/plugin-renderer-basic", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/plugin-renderer-basic"],\ + ["@stackflow/react", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:integrations/react"],\ + ["@swc/core", "virtual:b327d7e228fba669b88a8bb23bcf526374e46fa67e617b1e6848e8a205357fee5ce94b47c49b5a570fd9e8a44fa218a13cd00e2eca327c99114cbd21d72ecf9c#npm:1.6.6"],\ + ["@swc/jest", "virtual:b327d7e228fba669b88a8bb23bcf526374e46fa67e617b1e6848e8a205357fee5ce94b47c49b5a570fd9e8a44fa218a13cd00e2eca327c99114cbd21d72ecf9c#npm:0.2.36"],\ + ["@testing-library/dom", "npm:10.4.1"],\ + ["@testing-library/react", "virtual:7955ffc6169eeafa560ed57ab6078a9e38ce617ad7bac3088815f1abbf921580930bdb8dfeb035f838347bdf43112d8dc7b24b7cd880014e38befb3ebd69900c#npm:16.3.2"],\ + ["@types/jest", "npm:29.5.12"],\ + ["@types/react", "npm:18.3.3"],\ + ["@types/stackflow__core", null],\ + ["@types/stackflow__react", null],\ + ["esbuild", "npm:0.27.3"],\ + ["jest", "virtual:b327d7e228fba669b88a8bb23bcf526374e46fa67e617b1e6848e8a205357fee5ce94b47c49b5a570fd9e8a44fa218a13cd00e2eca327c99114cbd21d72ecf9c#npm:29.7.0"],\ + ["jest-environment-jsdom", "virtual:991015ceb8acca106af7e64cf676369bf8fb98370003b1af0559fb22931c330c3a09d064107412d6cc26ef286f0afdd26340443bd43177eeda3558644ba5f206#npm:29.7.0"],\ + ["react", "npm:18.3.1"],\ + ["react-dom", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#npm:18.3.1"],\ + ["rimraf", "npm:6.1.3"],\ + ["typescript", "patch:typescript@npm%3A5.5.3#optional!builtin::version=5.5.3&hash=379a07"]\ + ],\ + "packagePeers": [\ + "@stackflow/core",\ + "@stackflow/react",\ + "@types/react",\ + "@types/stackflow__core",\ + "@types/stackflow__react",\ + "react"\ + ],\ + "linkType": "SOFT"\ + }],\ ["workspace:extensions/plugin-blocker", {\ "packageLocation": "./extensions/plugin-blocker/",\ "packageDependencies": [\ @@ -6779,7 +6814,7 @@ const RAW_RUNTIME_STATE = ["@swc/core", "virtual:b327d7e228fba669b88a8bb23bcf526374e46fa67e617b1e6848e8a205357fee5ce94b47c49b5a570fd9e8a44fa218a13cd00e2eca327c99114cbd21d72ecf9c#npm:1.6.6"],\ ["@swc/jest", "virtual:b327d7e228fba669b88a8bb23bcf526374e46fa67e617b1e6848e8a205357fee5ce94b47c49b5a570fd9e8a44fa218a13cd00e2eca327c99114cbd21d72ecf9c#npm:0.2.36"],\ ["@testing-library/dom", "npm:10.4.1"],\ - ["@testing-library/react", "virtual:983596cc6314880cdf5646ccae28a297f9a9d9cc50891bcdd6486e5d19a65321933850dc7adb791ac89d6716f7185d6397520da0ee1852df1d3f86cb026a38fc#npm:16.3.2"],\ + ["@testing-library/react", "virtual:7955ffc6169eeafa560ed57ab6078a9e38ce617ad7bac3088815f1abbf921580930bdb8dfeb035f838347bdf43112d8dc7b24b7cd880014e38befb3ebd69900c#npm:16.3.2"],\ ["@types/jest", "npm:29.5.12"],\ ["@types/react", "npm:18.3.3"],\ ["esbuild", "npm:0.27.3"],\ @@ -6853,6 +6888,7 @@ const RAW_RUNTIME_STATE = ["@stackflow/config", "workspace:config"],\ ["@stackflow/core", "workspace:core"],\ ["@stackflow/esbuild-config", "workspace:packages/esbuild-config"],\ + ["@stackflow/plugin-blocker", "virtual:991015ceb8acca106af7e64cf676369bf8fb98370003b1af0559fb22931c330c3a09d064107412d6cc26ef286f0afdd26340443bd43177eeda3558644ba5f206#workspace:extensions/plugin-blocker"],\ ["@stackflow/plugin-renderer-basic", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/plugin-renderer-basic"],\ ["@stackflow/react", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:integrations/react"],\ ["@swc/core", "virtual:b327d7e228fba669b88a8bb23bcf526374e46fa67e617b1e6848e8a205357fee5ce94b47c49b5a570fd9e8a44fa218a13cd00e2eca327c99114cbd21d72ecf9c#npm:1.6.6"],\ @@ -6904,6 +6940,7 @@ const RAW_RUNTIME_STATE = ["@stackflow/config", "workspace:config"],\ ["@stackflow/core", "workspace:core"],\ ["@stackflow/esbuild-config", "workspace:packages/esbuild-config"],\ + ["@stackflow/plugin-blocker", "virtual:991015ceb8acca106af7e64cf676369bf8fb98370003b1af0559fb22931c330c3a09d064107412d6cc26ef286f0afdd26340443bd43177eeda3558644ba5f206#workspace:extensions/plugin-blocker"],\ ["@stackflow/plugin-renderer-basic", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:extensions/plugin-renderer-basic"],\ ["@stackflow/react", "virtual:413bca98ff76262f6f1f73762ccc4b7edee04a5da42f3d6b9ed2cb2d6dbc397b2094da59b50f6e828091c88e7b5f86990feff596c43f0eb50a58fc42aae64a20#workspace:integrations/react"],\ ["@swc/core", "virtual:b327d7e228fba669b88a8bb23bcf526374e46fa67e617b1e6848e8a205357fee5ce94b47c49b5a570fd9e8a44fa218a13cd00e2eca327c99114cbd21d72ecf9c#npm:1.6.6"],\ @@ -6948,7 +6985,7 @@ const RAW_RUNTIME_STATE = ["@swc/core", "virtual:b327d7e228fba669b88a8bb23bcf526374e46fa67e617b1e6848e8a205357fee5ce94b47c49b5a570fd9e8a44fa218a13cd00e2eca327c99114cbd21d72ecf9c#npm:1.6.6"],\ ["@swc/jest", "virtual:b327d7e228fba669b88a8bb23bcf526374e46fa67e617b1e6848e8a205357fee5ce94b47c49b5a570fd9e8a44fa218a13cd00e2eca327c99114cbd21d72ecf9c#npm:0.2.36"],\ ["@testing-library/dom", "npm:10.4.1"],\ - ["@testing-library/react", "virtual:983596cc6314880cdf5646ccae28a297f9a9d9cc50891bcdd6486e5d19a65321933850dc7adb791ac89d6716f7185d6397520da0ee1852df1d3f86cb026a38fc#npm:16.3.2"],\ + ["@testing-library/react", "virtual:7955ffc6169eeafa560ed57ab6078a9e38ce617ad7bac3088815f1abbf921580930bdb8dfeb035f838347bdf43112d8dc7b24b7cd880014e38befb3ebd69900c#npm:16.3.2"],\ ["@types/jest", "npm:29.5.12"],\ ["@types/react", "npm:18.3.3"],\ ["esbuild", "npm:0.27.3"],\ @@ -7397,10 +7434,10 @@ const RAW_RUNTIME_STATE = ],\ "linkType": "SOFT"\ }],\ - ["virtual:983596cc6314880cdf5646ccae28a297f9a9d9cc50891bcdd6486e5d19a65321933850dc7adb791ac89d6716f7185d6397520da0ee1852df1d3f86cb026a38fc#npm:16.3.2", {\ - "packageLocation": "./.yarn/__virtual__/@testing-library-react-virtual-f767e7b05a/0/cache/@testing-library-react-npm-16.3.2-67b0b894c8-0ca88c6f67.zip/node_modules/@testing-library/react/",\ + ["virtual:7955ffc6169eeafa560ed57ab6078a9e38ce617ad7bac3088815f1abbf921580930bdb8dfeb035f838347bdf43112d8dc7b24b7cd880014e38befb3ebd69900c#npm:16.3.2", {\ + "packageLocation": "./.yarn/__virtual__/@testing-library-react-virtual-b6e32a03d5/0/cache/@testing-library-react-npm-16.3.2-67b0b894c8-0ca88c6f67.zip/node_modules/@testing-library/react/",\ "packageDependencies": [\ - ["@testing-library/react", "virtual:983596cc6314880cdf5646ccae28a297f9a9d9cc50891bcdd6486e5d19a65321933850dc7adb791ac89d6716f7185d6397520da0ee1852df1d3f86cb026a38fc#npm:16.3.2"],\ + ["@testing-library/react", "virtual:7955ffc6169eeafa560ed57ab6078a9e38ce617ad7bac3088815f1abbf921580930bdb8dfeb035f838347bdf43112d8dc7b24b7cd880014e38befb3ebd69900c#npm:16.3.2"],\ ["@babel/runtime", "npm:7.25.0"],\ ["@testing-library/dom", "npm:10.4.1"],\ ["@types/react", "npm:18.3.3"],\ diff --git a/extensions/plugin-history-sync/package.json b/extensions/plugin-history-sync/package.json index 78813f24a..b6d653ab1 100644 --- a/extensions/plugin-history-sync/package.json +++ b/extensions/plugin-history-sync/package.json @@ -65,6 +65,7 @@ "@stackflow/config": "^2.0.0", "@stackflow/core": "^2.0.1", "@stackflow/esbuild-config": "^1.0.3", + "@stackflow/plugin-blocker": "workspace:^", "@stackflow/plugin-renderer-basic": "^1.1.14", "@stackflow/react": "^2.1.0", "@swc/core": "^1.6.6", diff --git a/extensions/plugin-history-sync/src/BrowserHistoryEntryModel.ts b/extensions/plugin-history-sync/src/BrowserHistoryEntryModel.ts new file mode 100644 index 000000000..3f37a0843 --- /dev/null +++ b/extensions/plugin-history-sync/src/BrowserHistoryEntryModel.ts @@ -0,0 +1,237 @@ +import type { State } from "./historyState"; +import { getStateStepId } from "./historyState"; + +export interface HistoryEntryIdentity { + activityId: string; + stepId: string; +} + +/** + * How the model came to know an entry. Only entries this session itself + * wrote (`"session-write"`) are ever eligible for rewriting/truncation when + * they diverge from the desired entries — they are this session's own + * product. Entries restored from the journal (`"journal"`, written by a + * previous session of this tab) and entries merely observed through a + * popstate (`"observation"`) are restoration targets and stay protected: + * being visited or journaled must never lift the protection. + */ +export type HistoryEntryProvenance = + | "session-write" + | "journal" + | "observation"; + +export interface KnownHistoryEntry { + identity: HistoryEntryIdentity; + + /** + * The state observed (or written) for this entry. Kept so that forward + * navigation across multiple entries can replay intermediate entries from + * their snapshots, and so that backward navigation can replay restored + * chains historically. + */ + state: State; + + /** + * The canonical path this plugin knows for the entry (written this + * session or restored from the journal), or `null` when the entry was + * only observed through a popstate. Lets the reconciler detect param-only + * divergence (same activity/step identity, different URL — e.g. an + * in-place replace that reuses the activityId). + */ + path: string | null; + + provenance: HistoryEntryProvenance; +} + +export function identityOfState(state: State): HistoryEntryIdentity { + return { + activityId: state.activity.id, + stepId: getStateStepId(state), + }; +} + +export function identityEquals( + a: HistoryEntryIdentity, + b: HistoryEntryIdentity, +): boolean { + return a.activityId === b.activityId && a.stepId === b.stepId; +} + +/** + * The plugin's model of the actual browser history ("actual" side of the + * reconciliation). Indexes are absolute in the plugin's own coordinate + * system: `0` is the entry that was current when the app booted fresh, or the + * persisted `entryIndex` of the current entry when the app booted from a + * serialized state. + * + * The model is intentionally allowed to be *partial*: entries written or + * visited during this session are known; entries written by previous sessions + * (still restorable through their serialized states) stay unknown until a + * `popstate` reveals them or the journal restores them at boot. The + * reconciler treats unknown entries optimistically and never rewrites them — + * nor any known entry this session did not write itself (see + * `HistoryEntryProvenance`) — which is what preserves cross-reload + * back/forward restoration. + */ +export class BrowserHistoryEntryModel { + private knownEntries = new Map(); + private _currentIndex = 0; + private _topIndex = 0; + private _anchorIndex = 0; + private _outOfApp = false; + + /** + * Cursor position in the browser history. + */ + get currentIndex(): number { + return this._currentIndex; + } + + /** + * The highest index this model believes to exist in the browser history. + */ + get topIndex(): number { + return this._topIndex; + } + + /** + * The absolute index the first desired entry maps to. The desired entries + * occupy `[anchorIndex, anchorIndex + desired.length - 1]`; everything + * below the anchor (external entries that predate the app, or entries of + * previous sessions not yet re-learned) must never be touched by the + * reconciler. + */ + get anchorIndex(): number { + return this._anchorIndex; + } + + /** + * True while the browser cursor rests on an entry that does not belong to + * the app (no parseable state) — e.g. the user navigated back past the + * app's first entry. Reconciliation is suspended until the cursor returns + * to an app entry. + */ + get outOfApp(): boolean { + return this._outOfApp; + } + + seed({ + currentIndex, + anchorIndex, + }: { + currentIndex: number; + anchorIndex: number; + }): void { + this.knownEntries.clear(); + this._currentIndex = currentIndex; + this._topIndex = currentIndex; + this._anchorIndex = anchorIndex; + this._outOfApp = false; + } + + setAnchorIndex(anchorIndex: number): void { + this._anchorIndex = anchorIndex; + } + + getEntry(index: number): KnownHistoryEntry | undefined { + return this.knownEntries.get(index); + } + + /** + * Whether the model believes an entry exists at `index` (it may still be + * unknown — written by a previous session). + */ + exists(index: number): boolean { + return index <= this._topIndex; + } + + /** + * Whether any entry above `index` was *written* by this session. Such + * entries are this session's own product — when they stop being desired + * they are a stale branch that may be truncated, unlike observed-only and + * journal-restored (previous-session) entries, which must be preserved as + * restoration targets. + */ + hasWrittenEntriesAbove(index: number): boolean { + for (const [entryIndex, entry] of this.knownEntries) { + if (entryIndex > index && entry.provenance === "session-write") { + return true; + } + } + + return false; + } + + learnEntry( + index: number, + entry: Omit & { + path?: string | null; + }, + ): void { + const known = this.knownEntries.get(index); + // Observations never erase the written-path knowledge of an entry, and + // never downgrade a session-write/journal provenance — visiting an entry + // must not lift its protection (nor grant rewrite eligibility). + const path = entry.path ?? known?.path ?? null; + const provenance = known?.provenance ?? "observation"; + + this.knownEntries.set(index, { ...entry, path, provenance }); + + if (index > this._topIndex) { + this._topIndex = index; + } + } + + /** + * Seeds an entry restored from the journal (written by a previous session + * of this tab). Deliberately does *not* raise `topIndex`: journal + * knowledge serves restoration and replay, but must not make the + * reconciler believe forward entries exist — a push right after boot has + * to truncate a stale forward branch via standard `pushState` semantics, + * exactly like the optimistic no-journal boot does. Physical confirmation + * that an entry exists arrives with the `popstate` that lands on it. + */ + restoreJournalEntry( + index: number, + entry: Omit, + ): void { + this.knownEntries.set(index, { ...entry, provenance: "journal" }); + } + + moveCursor(index: number): void { + this._currentIndex = index; + this._outOfApp = false; + } + + markOutOfApp(): void { + this._outOfApp = true; + } + + /** + * Records the effect of a `pushState` issued by the reconciler: the cursor + * advances and every entry beyond it is truncated by the browser. + */ + recordPush(entry: Omit): void { + const nextIndex = this._currentIndex + 1; + + for (const index of this.knownEntries.keys()) { + if (index >= nextIndex) { + this.knownEntries.delete(index); + } + } + + this._currentIndex = nextIndex; + this._topIndex = nextIndex; + this.knownEntries.set(nextIndex, { ...entry, provenance: "session-write" }); + } + + /** + * Records the effect of a `replaceState` issued by the reconciler. + */ + recordReplace(entry: Omit): void { + this.knownEntries.set(this._currentIndex, { + ...entry, + provenance: "session-write", + }); + } +} diff --git a/extensions/plugin-history-sync/src/HistoryEntryJournal.ts b/extensions/plugin-history-sync/src/HistoryEntryJournal.ts new file mode 100644 index 000000000..67c5a0a2b --- /dev/null +++ b/extensions/plugin-history-sync/src/HistoryEntryJournal.ts @@ -0,0 +1,273 @@ +import { parse, stringify } from "flatted"; +import { + type HistoryEntryIdentity, + identityEquals, + identityOfState, +} from "./BrowserHistoryEntryModel"; +import type { State } from "./historyState"; + +const JOURNAL_STORAGE_KEY = "@stackflow/plugin-history-sync::entry-journal"; +const JOURNAL_FORMAT_VERSION = 1; + +export interface JournalEntryRecord { + state: State; + path: string; +} + +interface JournalPayload { + version: typeof JOURNAL_FORMAT_VERSION; + entries: Array<[number, JournalEntryRecord]>; +} + +function isJournalEntryRecord(input: unknown): input is JournalEntryRecord { + if (typeof input !== "object" || input === null) { + return false; + } + + const record = input as Partial; + + return ( + typeof record.path === "string" && + typeof record.state === "object" && + record.state !== null && + typeof record.state.activity === "object" && + record.state.activity !== null && + typeof record.state.activity.id === "string" + ); +} + +function isJournalPayload(input: unknown): input is JournalPayload { + if (typeof input !== "object" || input === null) { + return false; + } + + const payload = input as Partial; + + return ( + payload.version === JOURNAL_FORMAT_VERSION && + Array.isArray(payload.entries) && + payload.entries.every( + (pair) => + Array.isArray(pair) && + pair.length === 2 && + typeof pair[0] === "number" && + isJournalEntryRecord(pair[1]), + ) + ); +} + +/** + * Persistent record of the browser history entries this app wrote during the + * current tab session (the tab session shares its lifetime with the browser + * history itself, hence `sessionStorage`). Serialized with `flatted`, the + * same codec `historyState.ts` uses for per-entry states, so restoration + * fidelity is identical. + * + * The journal lets a reloaded session re-learn entries it can no longer see + * through the History API: which entries exist behind/ahead of the current + * one, their activity/step identities, their original entry-event snapshots + * (for historical replay) and canonical paths. + * + * The in-memory map is the source of truth and every persist overwrites the + * full payload, so a storage failure can only ever freeze a consistent + * snapshot — never produce a partially-updated one. Boot-time validation + * (current entry identity + format version) arbitrates whether a stored + * snapshot still describes the live browser history; anything else falls + * back to the optimistic no-journal behavior. + * + * Journal failures are expected conditions (quota, privacy modes, embedders + * without storage): the journal degrades to a no-op with a one-time + * diagnostic and must never block reconciliation. Environments that simply + * lack storage (SSR, memory history) are silent no-ops by construction. + */ +export class HistoryEntryJournal { + private getStorage: () => Storage | undefined; + private enabled: boolean; + private entries = new Map(); + private warned = false; + + constructor({ + enabled, + getStorage, + }: { + enabled: boolean; + getStorage: () => Storage | undefined; + }) { + this.enabled = enabled; + this.getStorage = getStorage; + } + + /** + * Reads the persisted journal and validates it against the entry the app + * booted on. Returns the journaled entries on success; `null` means "boot + * without journal knowledge" (cold start, different coordinate system, + * corrupt data, storage unavailable). Invalid persisted data is cleared so + * that this session's writes start a coherent journal. + */ + loadValidated({ + expectedIndex, + expectedIdentity, + }: { + expectedIndex: number; + expectedIdentity: HistoryEntryIdentity; + }): ReadonlyMap | null { + const storage = this.safeGetStorage(); + + if (!storage) { + return null; + } + + let raw: string | null; + + try { + raw = storage.getItem(JOURNAL_STORAGE_KEY); + } catch (error) { + this.warnOnce("the history entry journal could not be read", error); + return null; + } + + if (raw === null) { + // Cold start (first visit in this tab) — expected, no diagnostic. + return null; + } + + let payload: unknown; + + try { + payload = parse(raw); + } catch (error) { + this.warnOnce("the persisted history entry journal is corrupt", error); + this.reset(); + return null; + } + + if (!isJournalPayload(payload)) { + this.warnOnce( + "the persisted history entry journal has an unknown format", + ); + this.reset(); + return null; + } + + const entries = new Map(payload.entries); + const currentRecord = entries.get(expectedIndex); + + if ( + !currentRecord || + !identityEquals(identityOfState(currentRecord.state), expectedIdentity) + ) { + // The journal describes a different history (another app instance on + // the same origin, external history manipulation, ...). Start over. + this.warnOnce( + "the persisted history entry journal does not match the current history entry", + ); + this.reset(); + return null; + } + + this.entries = entries; + + return entries; + } + + /** + * A fresh boot defines a new coordinate system (index `0` becomes the boot + * entry), so whatever journal a previous session left behind no longer + * describes this history. The journal restarts from this session's writes. + */ + resetForFreshBoot(): void { + this.reset(); + } + + /** + * Mirrors a reconciler write into the journal. A `pushState` truncates + * every entry beyond the written one (standard history semantics); a + * `replaceState` only updates its own slot. + */ + recordWrite( + index: number, + record: JournalEntryRecord, + { truncateAbove }: { truncateAbove: boolean }, + ): void { + if (!this.enabled) { + return; + } + + if (truncateAbove) { + for (const entryIndex of this.entries.keys()) { + if (entryIndex > index) { + this.entries.delete(entryIndex); + } + } + } + + this.entries.set(index, record); + this.persist(); + } + + private reset(): void { + this.entries.clear(); + + const storage = this.safeGetStorage(); + + if (!storage) { + return; + } + + try { + storage.removeItem(JOURNAL_STORAGE_KEY); + } catch (error) { + // A later successful persist overwrites the stale payload anyway. + this.warnOnce("the history entry journal could not be cleared", error); + } + } + + private persist(): void { + const storage = this.safeGetStorage(); + + if (!storage) { + return; + } + + const payload: JournalPayload = { + version: JOURNAL_FORMAT_VERSION, + entries: Array.from(this.entries.entries()), + }; + + try { + storage.setItem(JOURNAL_STORAGE_KEY, stringify(payload)); + } catch (error) { + // Quota/privacy failures freeze the last consistent snapshot; the next + // boot's validation decides whether it is still usable. Keep trying — + // a later write may succeed and self-heal (full overwrite). + this.warnOnce("the history entry journal could not be persisted", error); + } + } + + private safeGetStorage(): Storage | undefined { + if (!this.enabled) { + return undefined; + } + + try { + // The property access itself may throw (e.g. a SecurityError in + // third-party iframes or hardened privacy settings). + return this.getStorage(); + } catch (error) { + this.warnOnce("sessionStorage is not accessible", error); + return undefined; + } + } + + private warnOnce(message: string, error?: unknown): void { + if (this.warned) { + return; + } + + this.warned = true; + console.warn( + `[plugin-history-sync] ${message}; cross-reload restoration falls back to per-entry optimistic behavior`, + ...(error === undefined ? [] : [error]), + ); + } +} diff --git a/extensions/plugin-history-sync/src/HistoryReconciler.ts b/extensions/plugin-history-sync/src/HistoryReconciler.ts new file mode 100644 index 000000000..36b981768 --- /dev/null +++ b/extensions/plugin-history-sync/src/HistoryReconciler.ts @@ -0,0 +1,659 @@ +import { Action, type History, type Update } from "history"; +import { + BrowserHistoryEntryModel, + type HistoryEntryIdentity, + identityEquals, + identityOfState, +} from "./BrowserHistoryEntryModel"; +import type { DesiredHistoryEntry } from "./desiredHistoryEntries"; +import type { HistoryEntryJournal } from "./HistoryEntryJournal"; +import type { HistoryQueueContextValue } from "./HistoryQueueContext"; +import { + parseState, + pushState, + replaceState, + type State, +} from "./historyState"; +import { Mutex } from "./Mutex"; + +/** + * An unexpected contradiction between the model and the real browser history + * (an operation that cannot be expressed, or one that never completed). These + * are programming-error/desync conditions — expected situations (prevented + * navigations, unknown entries, out-of-app cursor) never raise this. + */ +class HistorySyncDesyncError extends Error { + constructor(message: string) { + super(`[plugin-history-sync] ${message}`); + this.name = "HistorySyncDesyncError"; + } +} + +/** + * Raised into pending operations when the reconciler is suspended (last + * `` unmounted). This is a normal teardown path, not an error. + */ +class ReconcilerSuspendedError extends Error { + constructor() { + super("[plugin-history-sync] reconciler suspended"); + this.name = "ReconcilerSuspendedError"; + } +} + +type GoExpectation = { + targetIndex: number; + expectedIdentity: HistoryEntryIdentity | null; + resolve: () => void; + reject: (error: Error) => void; +}; + +type ReconcileOp = + | { type: "go"; targetIndex: number } + | { type: "replace"; entry: DesiredHistoryEntry; index: number } + | { type: "push"; entry: DesiredHistoryEntry; index: number }; + +/** + * Upper bound for one reconcile pass. Each iteration performs at most one + * history operation, and a stable stack converges within `desired.length` + * operations — hitting this bound means the stack/browser pair keeps moving + * away faster than we converge, which is a desync. + */ +const MAX_RECONCILE_ITERATIONS = 100; + +/** + * A `history.go()` that never produces a `popstate` would block the serial + * queue forever. Out-of-range calls are rejected up front by model bounds + * checks; this timeout is the last-resort guard for cases the model cannot + * predict (e.g. an embedder truncating the history behind our back). + */ +const GO_TIMEOUT_MS = 10_000; + +export class HistoryReconciler { + private history: History; + private useHash?: boolean; + private computeDesired: () => DesiredHistoryEntry[]; + private onExternalPopState: (state: State | null) => void; + private journal: HistoryEntryJournal; + + readonly model = new BrowserHistoryEntryModel(); + + private taskQueue = new Mutex(); + private expectation: GoExpectation | null = null; + private reconcileScheduled = false; + private resyncing = false; + private listening: (() => void) | null = null; + private suspended = false; + private retainCount = 0; + + constructor({ + history, + useHash, + computeDesired, + onExternalPopState, + journal, + }: { + history: History; + useHash?: boolean; + computeDesired: () => DesiredHistoryEntry[]; + onExternalPopState: (state: State | null) => void; + journal: HistoryEntryJournal; + }) { + this.history = history; + this.useHash = useHash; + this.computeDesired = computeDesired; + this.onExternalPopState = onExternalPopState; + this.journal = journal; + } + + /** + * Boot on an entry without a serialized state: the current (external) + * entry is replaced by the root desired entry and the remaining desired + * entries are pushed on top, all synchronously. Index `0` is defined as + * the boot entry. + */ + initializeFreshBoot(desired: DesiredHistoryEntry[]): void { + this.model.seed({ currentIndex: 0, anchorIndex: 0 }); + // A fresh boot re-bases the coordinate system on the current physical + // entry, so a journal left behind by a previous session (the user + // navigated away and back without a restorable state) describes stale + // coordinates and must not survive. + this.journal.resetForFreshBoot(); + + desired.forEach((entry, index) => { + if (index === 0) { + this.writeReplace(entry, 0); + } else { + this.writePush(entry, index); + } + }); + } + + /** + * Boot on an entry carrying a serialized state (reload / re-entry). The + * browser history already holds this session's ancestors plus possibly + * entries from previous sessions; nothing is written. States serialized by + * older plugin versions carry no `entryIndex` — the coordinate system is + * then re-based on the current entry, which is upgraded in place. + * + * When the persisted journal validates against the current entry, the + * model is seeded with the journaled knowledge of previous sessions' + * entries (provenance `"journal"` — protected restoration targets). Only + * the model is seeded: stack restoration stays based on the current + * entry's snapshot alone, so boot UX and loader behavior are unchanged. An + * invalid/absent journal falls back to the plain optimistic boot. + */ + initializeRestored(state: State, desired: DesiredHistoryEntry[]): void { + const hasEntryIndex = typeof state.entryIndex === "number"; + const currentIndex = hasEntryIndex ? state.entryIndex! : 0; + + this.model.seed({ + currentIndex, + anchorIndex: currentIndex - Math.max(desired.length - 1, 0), + }); + + if (hasEntryIndex) { + const journalEntries = this.journal.loadValidated({ + expectedIndex: currentIndex, + expectedIdentity: identityOfState(state), + }); + + if (journalEntries) { + for (const [index, record] of journalEntries) { + this.model.restoreJournalEntry(index, { + identity: identityOfState(record.state), + state: record.state, + path: record.path, + }); + } + } + } else { + // Legacy states carry no coordinates, so the journal's indexes cannot + // be mapped onto this boot's re-based coordinate system. + this.journal.resetForFreshBoot(); + } + + this.model.learnEntry(currentIndex, { + identity: identityOfState(state), + state, + }); + + if (!hasEntryIndex && desired.length > 0) { + this.writeReplace(desired[desired.length - 1], currentIndex); + } + } + + /** + * Starts listening to the history instance. Safe to call repeatedly. + */ + start(): void { + this.suspended = false; + + if (this.listening) { + return; + } + + this.listening = this.history.listen((update) => { + this.handleHistoryUpdate(update); + }); + } + + /** + * Reference counting for mounted `` components. When the last one + * unmounts, the reconciler stops listening and rejects in-flight + * operations (a normal teardown, handled silently). The microtask deferral + * keeps React StrictMode's synchronous cleanup/re-run cycle from tearing + * the listener down. + */ + retain(): void { + this.retainCount += 1; + this.start(); + // The stack may have been dispatched to while no was mounted + // (reconciliation is suspended then — core actions stay usable); + // converge on (re-)mount. A no-op pass when already consistent. + this.requestReconcile(); + } + + release(): void { + this.retainCount -= 1; + + queueMicrotask(() => { + if (this.retainCount <= 0 && !this.suspended) { + this.suspend(); + } + }); + } + + private suspend(): void { + this.suspended = true; + this.reconcileScheduled = false; + this.listening?.(); + this.listening = null; + + const expectation = this.expectation; + this.expectation = null; + expectation?.reject(new ReconcilerSuspendedError()); + } + + /** + * Schedules a reconcile pass on the serial queue. Multiple requests + * coalesce into one pending pass; a pass always recomputes the desired + * entries from the latest stack, so changes that arrive while a pass is + * queued are folded into it. + */ + requestReconcile(): void { + if (this.reconcileScheduled || this.suspended) { + return; + } + + this.reconcileScheduled = true; + this.enqueue(async () => { + this.reconcileScheduled = false; + await this.reconcileOnce(); + this.resyncing = false; + }); + } + + /** + * Legacy serialized-history-task contract kept for `HistoryQueueContext` + * consumers: runs `cb` exclusively on the same queue as reconcile + * operations, completing on the next history tick or on explicit resolve. + */ + requestHistoryTick: HistoryQueueContextValue["requestHistoryTick"] = (cb) => { + this.enqueue( + () => + new Promise((resolve) => { + const clean = this.history.listen(() => { + clean(); + resolve(); + }); + + cb(() => { + clean(); + resolve(); + }); + }), + ); + }; + + private enqueue(task: () => Promise): void { + this.taskQueue.runExclusively(async () => { + if (this.suspended) { + return; + } + + try { + await task(); + } catch (error) { + if (error instanceof ReconcilerSuspendedError || this.suspended) { + return; + } + + console.error( + "[plugin-history-sync] history reconciliation failed; attempting to resynchronize from the current browser entry", + error, + ); + this.attemptResync(); + } + }); + } + + /** + * Last-resort recovery from a desync: rebuild the model from the only + * ground truth still available — the current entry's serialized state — + * then reconcile again. A second consecutive failure gives up loudly and + * waits for the next external event instead of looping. + */ + private attemptResync(): void { + if (this.resyncing) { + console.error( + "[plugin-history-sync] resynchronization failed twice in a row; suspending history writes until the next navigation", + ); + return; + } + + this.resyncing = true; + + const state = parseState(this.history.location.state); + + if (!state) { + this.model.markOutOfApp(); + return; + } + + const desired = this.computeDesired(); + const currentIndex = + typeof state.entryIndex === "number" + ? state.entryIndex + : this.model.currentIndex; + + this.model.seed({ + currentIndex, + anchorIndex: currentIndex - Math.max(desired.length - 1, 0), + }); + this.model.learnEntry(currentIndex, { + identity: identityOfState(state), + state, + }); + this.requestReconcile(); + } + + private handleHistoryUpdate(update: Update): void { + if (update.action !== Action.Pop) { + // Push/Replace updates through this History instance are either our + // own writes (already recorded in the model) or out-of-contract + // mutations by external code; neither is a navigation to interpret. + return; + } + + const state = parseState(update.location.state); + + if (this.consumeExpectation(state)) { + return; + } + + try { + this.onExternalPopState(state); + } finally { + // Converge unconditionally: if the navigation was prevented the stack + // did not change, and reconciliation restores the browser to it. + this.requestReconcile(); + } + } + + private consumeExpectation(state: State | null): boolean { + const expectation = this.expectation; + + if (!expectation || !state) { + return false; + } + + const matches = + typeof state.entryIndex === "number" + ? state.entryIndex === expectation.targetIndex + : expectation.expectedIdentity !== null && + identityEquals(identityOfState(state), expectation.expectedIdentity); + + if (!matches) { + return false; + } + + this.expectation = null; + // Move the cursor before resolving so that any synchronously following + // popstate handler reads an up-to-date model. + this.model.moveCursor(expectation.targetIndex); + this.model.learnEntry(expectation.targetIndex, { + identity: identityOfState(state), + state, + }); + expectation.resolve(); + + return true; + } + + /** + * One reconcile pass: converge the browser history onto the desired + * entries. Each iteration recomputes both sides and performs at most one + * history operation, which makes the pass robust against re-entrant stack + * dispatches and user navigations that interleave with our own operations. + */ + private async reconcileOnce(): Promise { + for (let iteration = 0; iteration < MAX_RECONCILE_ITERATIONS; iteration++) { + if (this.suspended || this.model.outOfApp) { + return; + } + + const desired = this.computeDesired(); + + if (desired.length === 0) { + // Transient stack state (e.g. teardown or initial setup in flight); + // there is nothing to converge onto. + return; + } + + const op = this.planNextOp(desired); + + if (op === null) { + return; + } + + await this.executeOp(op, desired); + } + + throw new HistorySyncDesyncError( + "reconciliation did not converge; the stack and the browser history keep diverging", + ); + } + + private planNextOp(desired: DesiredHistoryEntry[]): ReconcileOp | null { + const anchorIndex = this.model.anchorIndex; + + for (let j = 0; j < desired.length; j++) { + const index = anchorIndex + j; + + if (!this.model.exists(index)) { + // The entry does not exist yet: position the cursor on its + // predecessor (which must exist) and push from there. + const appendBase = index - 1; + + if (appendBase < anchorIndex) { + // The anchor entry itself is missing — the model lost track of the + // root, which initialization is supposed to guarantee. + throw new HistorySyncDesyncError( + `cannot append below the anchor (anchor=${anchorIndex}, append base=${appendBase})`, + ); + } + + if (this.model.currentIndex !== appendBase) { + return { type: "go", targetIndex: appendBase }; + } + + return { type: "push", entry: desired[j], index }; + } + + const known = this.model.getEntry(index); + + // Unknown entries (written by previous sessions, not yet re-learned) + // are treated as matching — they are restoration targets and must + // never be rewritten. The same protection extends to *known* entries + // this session did not write itself: journal-restored and + // observed-only entries stay restoration targets no matter how they + // diverge from the (possibly fictitious) boot mapping — being visited + // or journaled never lifts the protection. Only entries this session + // wrote are its own product: they diverge on identity, or on path when + // the identity is unchanged but the entry was rewritten with different + // params (e.g. an in-place replace reusing the activityId). + // + // One exception for protected entries: the *current* entry is + // rewritten when its recorded entry event differs from the desired one + // (an in-place replace right after a reload before anything was + // written, or a forward replay that re-entered the entry with a fresh + // event). Rewriting the entry the cursor rests on never destroys a + // restoration target. This refresh must always resolve as an in-place + // replace: it is only visible while the cursor rests on the entry, so + // routing it through a cursor-moving rebuild would flip the plan back + // and forth instead of converging. + const divergesAsOwnWrite = + known !== undefined && + known.provenance === "session-write" && + (!identityEquals(known.identity, { + activityId: desired[j].activityId, + stepId: desired[j].stepId, + }) || + (known.path !== null && known.path !== desired[j].path)); + const divergesAsCurrentEntryRefresh = + known !== undefined && + known.provenance !== "session-write" && + index === this.model.currentIndex && + known.state.activity.enteredBy.id !== + desired[j].state.activity.enteredBy.id; + const diverges = divergesAsOwnWrite || divergesAsCurrentEntryRefresh; + + if (diverges) { + const targetCursor = anchorIndex + desired.length - 1; + + // A divergent rewrite of the *last* desired entry while entries this + // session itself wrote linger above it means that suffix is a stale + // branch our own navigation produced (e.g. a replace that shrank the + // entry list). Rebuild the entry with a push so the browser + // truncates the whole branch. Observed-only/previous-session + // suffixes keep the optimistic invariant and the in-place rewrite + // below — they are restoration targets. The root entry can only be + // rewritten in place (nothing to push from). + const mustTruncateStaleSuffix = + divergesAsOwnWrite && + index === targetCursor && + index > anchorIndex && + this.model.hasWrittenEntriesAbove(index); + + if (this.model.currentIndex < index || mustTruncateStaleSuffix) { + // The divergent entry lies ahead of the cursor (a stale forward + // branch — e.g. entries left over from before a browser back), or + // it must cut a stale suffix: rebuild it with a push, per standard + // pushState truncation semantics. Never walk forward into a stale + // branch — entries between the cursor and the branch point were + // verified matching by this scan, so positioning on the + // predecessor is safe. + if (this.model.currentIndex !== index - 1) { + return { type: "go", targetIndex: index - 1 }; + } + + return { type: "push", entry: desired[j], index }; + } + + if (this.model.currentIndex > index) { + // The divergent entry is behind the cursor: move back to it and + // rewrite it in place on the next iteration. (`replaceState` + // preserves entries beyond it — those are still desired here, and + // get repaired index by index on subsequent iterations.) + return { type: "go", targetIndex: index }; + } + + return { type: "replace", entry: desired[j], index }; + } + } + + const targetCursor = anchorIndex + desired.length - 1; + + if (this.model.currentIndex !== targetCursor) { + return { type: "go", targetIndex: targetCursor }; + } + + return null; + } + + private async executeOp( + op: ReconcileOp, + desired: DesiredHistoryEntry[], + ): Promise { + switch (op.type) { + case "go": { + if ( + op.targetIndex < this.model.anchorIndex || + !this.model.exists(op.targetIndex) + ) { + throw new HistorySyncDesyncError( + `go target out of the app-owned range (target=${op.targetIndex}, anchor=${this.model.anchorIndex}, top=${this.model.topIndex})`, + ); + } + + const desiredPosition = op.targetIndex - this.model.anchorIndex; + const expectedIdentity: HistoryEntryIdentity | null = + this.model.getEntry(op.targetIndex)?.identity ?? + (desiredPosition >= 0 && desiredPosition < desired.length + ? { + activityId: desired[desiredPosition].activityId, + stepId: desired[desiredPosition].stepId, + } + : null); + + await this.executeGo(op.targetIndex, expectedIdentity); + return; + } + case "replace": { + this.writeReplace(op.entry, op.index); + return; + } + case "push": { + this.writePush(op.entry, op.index); + return; + } + } + } + + private executeGo( + targetIndex: number, + expectedIdentity: HistoryEntryIdentity | null, + ): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + if (this.expectation?.targetIndex === targetIndex) { + this.expectation = null; + reject( + new HistorySyncDesyncError( + `history.go(${targetIndex - this.model.currentIndex}) produced no popstate within ${GO_TIMEOUT_MS}ms`, + ), + ); + } + }, GO_TIMEOUT_MS); + + this.expectation = { + targetIndex, + expectedIdentity, + resolve: () => { + clearTimeout(timeout); + resolve(); + }, + reject: (error) => { + clearTimeout(timeout); + reject(error); + }, + }; + + // The expectation is registered before issuing the operation: memory + // histories notify listeners synchronously inside `go()`. + this.history.go(targetIndex - this.model.currentIndex); + }); + } + + private writePush(entry: DesiredHistoryEntry, index: number): void { + const state: State = { ...entry.state, entryIndex: index }; + + pushState({ + history: this.history, + pathname: entry.path, + state, + useHash: this.useHash, + }); + this.model.recordPush({ + identity: { activityId: entry.activityId, stepId: entry.stepId }, + state, + path: entry.path, + }); + this.journal.recordWrite( + index, + { state, path: entry.path }, + { truncateAbove: true }, + ); + } + + private writeReplace(entry: DesiredHistoryEntry, index: number): void { + const state: State = { ...entry.state, entryIndex: index }; + + replaceState({ + history: this.history, + pathname: entry.path, + state, + useHash: this.useHash, + }); + this.model.recordReplace({ + identity: { activityId: entry.activityId, stepId: entry.stepId }, + state, + path: entry.path, + }); + this.journal.recordWrite( + index, + { state, path: entry.path }, + { truncateAbove: false }, + ); + } +} diff --git a/extensions/plugin-history-sync/src/desiredHistoryEntries.ts b/extensions/plugin-history-sync/src/desiredHistoryEntries.ts new file mode 100644 index 000000000..d45fea4b8 --- /dev/null +++ b/extensions/plugin-history-sync/src/desiredHistoryEntries.ts @@ -0,0 +1,141 @@ +import type { Activity, Stack } from "@stackflow/core"; +import type { ActivityRoute } from "./ActivityRoute"; +import type { State } from "./historyState"; +import type { UrlPatternOptions } from "./makeTemplate"; +import { makeTemplate } from "./makeTemplate"; + +/** + * One browser history entry the current stack expects to exist. The desired + * entry list is the render target the reconciler converges the actual browser + * history onto. + */ +export interface DesiredHistoryEntry { + activityId: string; + stepId: string; + path: string; + + /** + * The state to serialize into the browser entry (without `entryIndex`, + * which is assigned by the reconciler when the entry is written). + */ + state: State; +} + +function isEntered(activity: Activity): boolean { + return ( + activity.transitionState === "enter-active" || + activity.transitionState === "enter-done" + ); +} + +/** + * Computes the desired browser history entries from the current stack: one + * entry per live step of every entered activity (an activity's first live + * step is the activity's own entry and is serialized without a `step` field, + * matching the legacy state shape). + * + * Replace special case: while a `Replaced` activity's enter transition is + * still in flight, the core has not yet marked the activity it replaces as + * exited (that happens when the transition completes — even if the replacing + * activity itself gets popped in the meantime). The replaced activity must + * not occupy a desired entry during that window — otherwise a replace would + * transiently grow the browser history by one entry and then shrink it back. + * So for every `Replaced` entry event that displaced a predecessor and whose + * victim is not marked yet (no activity carries it as `exitedBy`), we drop + * the closest surviving predecessor, mirroring what the core will do when + * the transition settles. + * + * An *in-place* replace — a `Replaced` event reusing an activityId that + * already exists in the stack (reachable via the public + * `replace(name, params, { activityId })` API) — updates that activity's + * slot and never displaces anything: the core's `findTargetActivityIndices` + * skips victim marking entirely for it. Such events must not drop a + * predecessor (the victim would never be marked, so the drop would otherwise + * apply forever). They are detected through the event log: a prior entry + * event with the same activityId means the slot already existed. + * + * Ordering note: this module orders activities by `enteredBy.eventDate` + * (navigation time), while the core's `isActive`/render order follows the + * `activities` array slot order. The two agree for every navigation this + * plugin itself dispatches (forward restorations only re-enter entries above + * the current position, whose slots are also on top). User code that + * re-enters a historical slot behind the active one by passing an explicit + * old `activityId` to `push` — or that in-place-replaces an *inactive* + * activity via `replace(..., { activityId })`, which refreshes its + * `enteredBy.eventDate` and reorders it to the end of the desired list — can + * make them diverge; both are outside this plugin's contract. + */ +export function computeDesiredHistoryEntries({ + stack, + activityRoutes, + urlPatternOptions, +}: { + stack: Stack; + activityRoutes: ActivityRoute[]; + urlPatternOptions?: UrlPatternOptions; +}): DesiredHistoryEntry[] { + const activitiesInNavigationOrder = stack.activities + .slice() + .sort((a, b) => a.enteredBy.eventDate - b.enteredBy.eventDate); + + const surviving: Activity[] = []; + + for (const activity of activitiesInNavigationOrder) { + if (activity.enteredBy.name === "Replaced" && surviving.length > 0) { + const enteredBy = activity.enteredBy; + + const reusedExistingActivity = stack.events.some( + (event) => + (event.name === "Pushed" || event.name === "Replaced") && + event.id !== enteredBy.id && + event.activityId === enteredBy.activityId && + event.eventDate <= enteredBy.eventDate, + ); + const victimAlreadyMarked = stack.activities.some( + (candidate) => candidate.exitedBy?.id === enteredBy.id, + ); + + if (!reusedExistingActivity && !victimAlreadyMarked) { + surviving.pop(); + } + } + + if (isEntered(activity)) { + surviving.push(activity); + } + } + + const entries: DesiredHistoryEntry[] = []; + + for (const activity of surviving) { + const activityRoute = activityRoutes.find( + (route) => route.activityName === activity.name, + ); + + if (!activityRoute) { + continue; + } + + const template = makeTemplate(activityRoute, urlPatternOptions); + const liveSteps = activity.steps.filter((step) => !step.exitedBy); + + liveSteps.forEach((step, stepIndex) => { + entries.push({ + activityId: activity.id, + stepId: step.id, + path: template.fill(step.params), + state: + stepIndex === 0 + ? { + activity, + } + : { + activity, + step, + }, + }); + }); + } + + return entries; +} diff --git a/extensions/plugin-history-sync/src/historyState.ts b/extensions/plugin-history-sync/src/historyState.ts index 3cace0f7e..46098be44 100644 --- a/extensions/plugin-history-sync/src/historyState.ts +++ b/extensions/plugin-history-sync/src/historyState.ts @@ -4,9 +4,18 @@ import type { History } from "history"; const STATE_TAG = "@stackflow/plugin-history-sync"; -interface State { +export interface State { activity: Activity; step?: ActivityStep; + + /** + * Absolute position of this entry in the plugin's own coordinate system + * (`0` = the entry that was current when the app first booted). Used by the + * reconciler to identify self-induced `popstate` events and to compute + * `history.go()` deltas. Absent in states serialized by older plugin + * versions; those fall back to identity-based handling. + */ + entryIndex?: number; } interface SerializedState { @@ -20,6 +29,7 @@ function serializeState(state: State): SerializedState { flattedState: stringify({ activity: state.activity, step: state.step, + entryIndex: state.entryIndex, }), }; } @@ -44,6 +54,18 @@ export function parseState(input: unknown): State | null { } } +/** + * The browser entry that represents an activity itself (its first step) is + * serialized without a `step` field, so the step identity of a parsed state + * falls back to the activity's first step. The core guarantees that an + * activity's first step shares the activity's id (`makeActivityFromEvent`), + * which keeps this identity stable even across re-pushes of the same + * activity. + */ +export function getStateStepId(state: State): string { + return state.step?.id ?? state.activity.steps?.[0]?.id ?? state.activity.id; +} + export function pushState({ history, pathname, diff --git a/extensions/plugin-history-sync/src/historySyncPlugin.blocker.spec.tsx b/extensions/plugin-history-sync/src/historySyncPlugin.blocker.spec.tsx new file mode 100644 index 000000000..35a2d7606 --- /dev/null +++ b/extensions/plugin-history-sync/src/historySyncPlugin.blocker.spec.tsx @@ -0,0 +1,1513 @@ +/** @jest-environment jsdom */ +import { defineConfig } from "@stackflow/config"; +import type { Stack, StackflowActions } from "@stackflow/core"; +import { + type BlockedNavigation, + blockerPlugin, + type NavigationAction, + useBlocker, +} from "@stackflow/plugin-blocker"; +import { basicRendererPlugin } from "@stackflow/plugin-renderer-basic"; +import type { StackflowReactPlugin } from "@stackflow/react"; +import { stackflow } from "@stackflow/react"; +import { act, cleanup, render } from "@testing-library/react"; +import { createBrowserHistory } from "history"; +import { historySyncPlugin } from "./historySyncPlugin"; + +declare module "@stackflow/config" { + interface Register { + Home: {}; + Article: { + articleId: string; + }; + } +} + +type BlockerControls = { + shouldBlock: (action: NavigationAction) => boolean; + onBlocked: ( + blockedNavigation: BlockedNavigation, + actions: { proceed: () => void }, + ) => void; +}; + +type Harness = Awaited>; +type FallbackActivity = (args: { initialContext: unknown }) => "Home"; +type SessionStorageFault = "getItem" | "setItem" | "removeItem" | "clear"; + +type SessionStorageShim = Storage & { + setFaults: (faults: SessionStorageFault[]) => void; +}; +type SessionStorageAccess = SessionStorageShim | null | "throw"; + +let currentBlocker: BlockerControls | null = null; +let restoreSessionStorage: (() => void) | null = null; + +function Home() { + return
home
; +} + +function Article() { + useBlocker({ + shouldBlock: (action) => currentBlocker?.shouldBlock(action) ?? false, + onBlocked: (blockedNavigation, actions) => { + currentBlocker?.onBlocked(blockedNavigation, actions); + }, + }); + + return
article
; +} + +function path(browserWindow: Window) { + return ( + browserWindow.location.pathname + + browserWindow.location.search + + browserWindow.location.hash + ); +} + +function makeSessionStorageShim({ + initialEntries = {}, + faults = [], +}: { + initialEntries?: Record; + faults?: SessionStorageFault[]; +} = {}): SessionStorageShim { + const entries = new Map(Object.entries(initialEntries)); + let activeFaults = new Set(faults); + const assertAvailable = (operation: SessionStorageFault) => { + if (activeFaults.has(operation)) { + throw new DOMException( + `sessionStorage ${operation} failed`, + "QuotaExceededError", + ); + } + }; + + return { + get length() { + return entries.size; + }, + clear() { + assertAvailable("clear"); + entries.clear(); + }, + getItem(key: string) { + assertAvailable("getItem"); + return entries.get(key) ?? null; + }, + key(index: number) { + return Array.from(entries.keys())[index] ?? null; + }, + removeItem(key: string) { + assertAvailable("removeItem"); + entries.delete(key); + }, + setFaults(faults: SessionStorageFault[]) { + activeFaults = new Set(faults); + }, + setItem(key: string, value: string) { + assertAvailable("setItem"); + entries.set(key, value); + }, + }; +} + +function readSessionStorageAccess(sessionStorage: SessionStorageAccess) { + if (sessionStorage === "throw") { + throw new DOMException("sessionStorage access denied", "SecurityError"); + } + + return sessionStorage ?? undefined; +} + +function installSessionStorageShim( + sessionStorage: SessionStorageAccess, +): () => void { + const descriptor = Object.getOwnPropertyDescriptor(window, "sessionStorage"); + + Object.defineProperty(window, "sessionStorage", { + configurable: true, + get() { + return readSessionStorageAccess(sessionStorage); + }, + }); + + return () => { + if (descriptor) { + Object.defineProperty(window, "sessionStorage", descriptor); + } else { + Reflect.deleteProperty(window, "sessionStorage"); + } + }; +} + +function makeBrowserWindow({ + initialPath, + sessionStorage, +}: { + initialPath: string; + sessionStorage: SessionStorageAccess; +}): Window { + const eventTarget = new EventTarget(); + const entries: Array<{ path: string; state: unknown }> = [ + { path: initialPath, state: null }, + ]; + let index = 0; + const locationState = { + href: "", + pathname: "/", + search: "", + hash: "", + assign(url: string | URL) { + setLocation(String(url)); + }, + } as unknown as Location; + + const setLocation = (url: string | URL) => { + const nextUrl = new URL( + String(url || entries[index].path), + "http://localhost", + ); + + locationState.href = nextUrl.href; + locationState.pathname = nextUrl.pathname; + locationState.search = nextUrl.search; + locationState.hash = nextUrl.hash; + }; + + const dispatchPopState = () => { + setTimeout(() => { + eventTarget.dispatchEvent( + new PopStateEvent("popstate", { state: entries[index].state }), + ); + }, 0); + }; + + const historyApi = { + get length() { + return entries.length; + }, + get state() { + return entries[index].state; + }, + pushState(state: unknown, _: string, url?: string | URL | null) { + const nextPath = String(url || entries[index].path); + + entries.splice(index + 1); + entries.push({ path: nextPath, state }); + index = entries.length - 1; + setLocation(nextPath); + }, + replaceState(state: unknown, _: string, url?: string | URL | null) { + const nextPath = String(url || entries[index].path); + + entries[index] = { path: nextPath, state }; + setLocation(nextPath); + }, + go(delta: number) { + const nextIndex = index + delta; + + if (nextIndex < 0 || nextIndex >= entries.length || nextIndex === index) { + return; + } + + index = nextIndex; + setLocation(entries[index].path); + dispatchPopState(); + }, + back() { + this.go(-1); + }, + forward() { + this.go(1); + }, + } as History; + + setLocation(initialPath); + + const browserWindow = { + history: historyApi, + location: locationState, + addEventListener: eventTarget.addEventListener.bind(eventTarget), + removeEventListener: eventTarget.removeEventListener.bind(eventTarget), + } as unknown as Window; + + Object.defineProperty(browserWindow, "sessionStorage", { + configurable: true, + get() { + return readSessionStorageAccess(sessionStorage); + }, + }); + + return browserWindow; +} + +function activeActivity(stack: Stack) { + return stack.activities.find((activity) => activity.isActive); +} + +function activeSnapshot(getStack: () => Stack) { + const active = activeActivity(getStack()); + const liveSteps = active?.steps.filter((step) => !step.exitedBy) ?? []; + const activeStep = liveSteps[liveSteps.length - 1]; + + return { + name: active?.name, + params: active?.params ?? {}, + stepParams: activeStep?.params ?? {}, + stepCount: liveSteps.length, + activityCount: getStack().activities.filter( + (activity) => !activity.exitedBy, + ).length, + transition: getStack().globalTransitionState, + }; +} + +function serializableSnapshot(harness: { + baseHistoryLength: number; + browserWindow: Window; + getStack: () => Stack; +}) { + return { + url: path(harness.browserWindow), + historyLengthDelta: + harness.browserWindow.history.length - harness.baseHistoryLength, + active: activeSnapshot(harness.getStack), + }; +} + +async function settleUntilStable( + harness: { + baseHistoryLength: number; + browserWindow: Window; + getStack: () => Stack; + }, + selectSnapshot: () => unknown = () => serializableSnapshot(harness), +) { + let previous = ""; + let stableSamples = 0; + + for (let i = 0; i < 60; i += 1) { + await act(async () => { + jest.advanceTimersByTime(17); + await Promise.resolve(); + await Promise.resolve(); + }); + + const next = JSON.stringify(selectSnapshot()); + if (next === previous) { + stableSamples += 1; + if (stableSamples >= 2) { + return; + } + } else { + previous = next; + stableSamples = 0; + } + } + + throw new Error(`historySyncPlugin test harness did not settle: ${previous}`); +} + +async function renderHarness({ + initialPath = "/", + browserWindow, + baseHistoryLength, + sessionStorage = makeSessionStorageShim(), + blocker, + fallbackActivity = () => "Home", +}: { + initialPath?: string; + browserWindow?: Window; + baseHistoryLength?: number; + sessionStorage?: SessionStorageAccess; + blocker?: BlockerControls; + fallbackActivity?: FallbackActivity; +} = {}) { + currentBlocker = blocker ?? null; + restoreSessionStorage?.(); + restoreSessionStorage = installSessionStorageShim(sessionStorage); + const targetBrowserWindow = + browserWindow ?? + makeBrowserWindow({ + initialPath, + sessionStorage, + }); + const targetBaseHistoryLength = + baseHistoryLength ?? targetBrowserWindow.history.length; + const history = createBrowserHistory({ window: targetBrowserWindow }); + const captured: { actions?: StackflowActions } = {}; + + const captureActionsPlugin: StackflowReactPlugin = () => ({ + key: "capture-actions", + onInit({ actions }) { + captured.actions = actions; + }, + }); + + const config = defineConfig({ + transitionDuration: 0, + activities: [ + { name: "Home", route: "/home" }, + { name: "Article", route: "/articles/:articleId" }, + ], + }); + + const { Stack, actions, stepActions } = stackflow({ + config, + components: { + Home, + Article, + }, + plugins: [ + blockerPlugin(), + basicRendererPlugin(), + historySyncPlugin({ + config, + history, + fallbackActivity, + }), + captureActionsPlugin, + ], + }); + + const view = render(); + + const getStack = () => { + if (!captured.actions) { + throw new Error("Stackflow core actions were not captured"); + } + + return captured.actions.getStack(); + }; + + const harness = { + actions, + stepActions, + coreActions: captured.actions, + history, + baseHistoryLength: targetBaseHistoryLength, + browserWindow: targetBrowserWindow, + sessionStorage, + getStack, + currentPath: () => path(targetBrowserWindow), + snapshot: () => + serializableSnapshot({ + baseHistoryLength: targetBaseHistoryLength, + browserWindow: targetBrowserWindow, + getStack, + }), + settle: (selectSnapshot?: () => unknown) => + settleUntilStable( + { + baseHistoryLength: targetBaseHistoryLength, + browserWindow: targetBrowserWindow, + getStack, + }, + selectSnapshot, + ), + view, + }; + + await harness.settle(); + + return harness; +} + +async function reloadHarness( + harness: Harness, + options: { + blocker?: BlockerControls; + fallbackActivity?: FallbackActivity; + sessionStorage?: SessionStorageAccess; + } = {}, +) { + await act(async () => { + harness.view.unmount(); + await Promise.resolve(); + }); + + return renderHarness({ + browserWindow: harness.browserWindow, + baseHistoryLength: harness.baseHistoryLength, + sessionStorage: + "sessionStorage" in options + ? options.sessionStorage! + : harness.sessionStorage, + blocker: options.blocker, + fallbackActivity: options.fallbackActivity, + }); +} + +async function pushArticle(harness: Harness, articleId: string) { + await act(async () => { + harness.actions.push("Article", { articleId }); + }); + await harness.settle(); +} + +async function pushArticleStep( + harness: Harness, + params: { articleId: string; tab?: string }, +) { + await act(async () => { + harness.stepActions.pushStep(params); + }); + await harness.settle(); +} + +async function expectLocationAfterBrowserMove( + harness: Harness, + move: () => void, + expected: { + url: string; + activeName: string; + articleId?: string; + tab?: string; + }, +) { + await act(async () => { + move(); + }); + await harness.settle(); + + expect(harness.currentPath()).toBe(expected.url); + expect(activeSnapshot(harness.getStack)).toMatchObject({ + name: expected.activeName, + params: expected.articleId ? { articleId: expected.articleId } : {}, + stepParams: expected.articleId + ? { + articleId: expected.articleId, + ...(expected.tab ? { tab: expected.tab } : {}), + } + : {}, + }); +} + +async function createReloadedStepBoundaryHarness({ + blocker, + sessionStorage = makeSessionStorageShim(), +}: { + blocker?: BlockerControls; + sessionStorage?: SessionStorageAccess; +} = {}) { + const harness = await renderHarness({ sessionStorage }); + + await pushArticle(harness, "root"); + await pushArticleStep(harness, { + articleId: "middle", + tab: "comments", + }); + await pushArticleStep(harness, { + articleId: "top", + tab: "details", + }); + + return reloadHarness(harness, { blocker, sessionStorage }); +} + +async function createReloadedActivityChainHarness({ + blocker, + sessionStorage = makeSessionStorageShim(), +}: { + blocker?: BlockerControls; + sessionStorage?: SessionStorageAccess; +} = {}) { + const harness = await renderHarness({ blocker, sessionStorage }); + + await pushArticle(harness, "10"); + await pushArticle(harness, "20"); + await pushArticle(harness, "30"); + + return reloadHarness(harness, { blocker, sessionStorage }); +} + +describe("historySyncPlugin - deterministic browser harness", () => { + beforeEach(() => { + jest.useFakeTimers(); + currentBlocker = null; + }); + + afterEach(() => { + cleanup(); + currentBlocker = null; + restoreSessionStorage?.(); + restoreSessionStorage = null; + jest.useRealTimers(); + }); + + describe("plugin-blocker interop", () => { + it("browser back passes through blocker hooks and restores URL/stack when blocked", async () => { + const onBlocked = jest.fn(); + const shouldBlock = jest.fn( + (action: NavigationAction) => action.name === "Popped", + ); + const harness = await renderHarness({ + blocker: { + shouldBlock, + onBlocked, + }, + }); + await pushArticle(harness, "1"); + await pushArticle(harness, "2"); + + const before = harness.snapshot(); + expect(before).toMatchObject({ + url: "/articles/2/", + active: { + name: "Article", + params: { articleId: "2" }, + activityCount: 3, + }, + }); + + await act(async () => { + harness.history.back(); + }); + await harness.settle(() => ({ + snapshot: harness.snapshot(), + shouldBlockCalls: shouldBlock.mock.calls.length, + onBlockedCalls: onBlocked.mock.calls.length, + })); + + expect(shouldBlock).toHaveBeenCalledWith( + expect.objectContaining({ name: "Popped" }), + ); + expect(onBlocked).toHaveBeenCalledTimes(1); + expect(harness.snapshot()).toEqual(before); + }); + + it("browser back proceed replays the blocked navigation and syncs browser history", async () => { + let proceed: (() => void) | null = null; + const harness = await renderHarness({ + blocker: { + shouldBlock: (action) => action.name === "Popped", + onBlocked: (_, actions) => { + proceed = actions.proceed; + }, + }, + }); + await pushArticle(harness, "1"); + await pushArticle(harness, "2"); + + await act(async () => { + harness.history.back(); + }); + await harness.settle(() => ({ + snapshot: harness.snapshot(), + hasProceed: proceed !== null, + })); + expect(proceed).toEqual(expect.any(Function)); + + await act(async () => { + proceed?.(); + }); + await harness.settle(); + + expect(harness.currentPath()).toBe("/articles/1/"); + expect(activeSnapshot(harness.getStack)).toMatchObject({ + name: "Article", + params: { articleId: "1" }, + stepParams: { articleId: "1" }, + }); + }); + + it("rapid browser back attempts while blocked converge without losing the top activity", async () => { + const onBlocked = jest.fn(); + const harness = await renderHarness({ + blocker: { + shouldBlock: (action) => action.name === "Popped", + onBlocked, + }, + }); + await pushArticle(harness, "1"); + await pushArticle(harness, "2"); + await pushArticle(harness, "3"); + + const before = harness.snapshot(); + expect(before).toMatchObject({ + url: "/articles/3/", + active: { + name: "Article", + params: { articleId: "3" }, + activityCount: 4, + }, + }); + + await act(async () => { + harness.history.back(); + harness.history.back(); + }); + await harness.settle(() => ({ + snapshot: harness.snapshot(), + blockedCount: onBlocked.mock.calls.length, + })); + + expect(onBlocked).toHaveBeenCalled(); + expect(harness.snapshot()).toEqual(before); + }); + + it("blocked browser step back restores the current step URL and stack", async () => { + const onBlocked = jest.fn(); + const harness = await renderHarness({ + blocker: { + shouldBlock: (action) => action.name === "StepPopped", + onBlocked, + }, + }); + await pushArticle(harness, "1"); + await pushArticleStep(harness, { articleId: "1", tab: "comments" }); + + const before = harness.snapshot(); + + await act(async () => { + harness.history.back(); + }); + await harness.settle(() => ({ + snapshot: harness.snapshot(), + blockedCount: onBlocked.mock.calls.length, + })); + + expect(onBlocked).toHaveBeenCalledWith( + expect.objectContaining({ + action: expect.objectContaining({ name: "StepPopped" }), + }), + expect.anything(), + ); + expect(harness.snapshot()).toEqual(before); + }); + + it("blocked programmatic pop/stepPop do not mutate URL or browser entries", async () => { + const onBlocked = jest.fn(); + const harness = await renderHarness({ + blocker: { + shouldBlock: (action) => + action.name === "Popped" || action.name === "StepPopped", + onBlocked, + }, + }); + await pushArticle(harness, "1"); + await pushArticleStep(harness, { articleId: "1", tab: "comments" }); + + const beforeStepPop = harness.snapshot(); + + await act(async () => { + harness.stepActions.popStep(); + }); + await harness.settle(() => ({ + snapshot: harness.snapshot(), + blockedCount: onBlocked.mock.calls.length, + })); + + expect(harness.snapshot()).toEqual(beforeStepPop); + + const beforePop = harness.snapshot(); + + await act(async () => { + harness.actions.pop(); + }); + await harness.settle(() => ({ + snapshot: harness.snapshot(), + blockedCount: onBlocked.mock.calls.length, + })); + + expect(harness.snapshot()).toEqual(beforePop); + }); + + it("blocked programmatic push/replace/stepPush/stepReplace leave URL and browser entries unchanged", async () => { + const onBlocked = jest.fn(); + const harness = await renderHarness({ + blocker: { + shouldBlock: (action) => + action.name === "Pushed" || + action.name === "Replaced" || + action.name === "StepPushed" || + action.name === "StepReplaced", + onBlocked, + }, + }); + await pushArticle(harness, "1"); + + const beforePush = harness.snapshot(); + await act(async () => { + harness.actions.push("Home", {}); + }); + await harness.settle(() => ({ + snapshot: harness.snapshot(), + blockedCount: onBlocked.mock.calls.length, + })); + expect(harness.snapshot()).toEqual(beforePush); + + const beforeReplace = harness.snapshot(); + await act(async () => { + harness.actions.replace("Home", {}); + }); + await harness.settle(); + expect(harness.snapshot()).toEqual(beforeReplace); + + const beforeStepPush = harness.snapshot(); + await act(async () => { + harness.stepActions.pushStep({ articleId: "1", tab: "blocked" }); + }); + await harness.settle(); + expect(harness.snapshot()).toEqual(beforeStepPush); + + const beforeStepReplace = harness.snapshot(); + await act(async () => { + harness.stepActions.replaceStep({ articleId: "1", tab: "blocked" }); + }); + await harness.settle(); + expect(harness.snapshot()).toEqual(beforeStepReplace); + + expect(onBlocked).toHaveBeenCalledTimes(4); + }); + + it("programmatic blocked pop completes and syncs history after proceed", async () => { + let proceed: (() => void) | null = null; + const harness = await renderHarness({ + blocker: { + shouldBlock: (action) => action.name === "Popped", + onBlocked: (_, actions) => { + proceed = actions.proceed; + }, + }, + }); + await pushArticle(harness, "1"); + await pushArticle(harness, "2"); + + await act(async () => { + harness.actions.pop(); + }); + await harness.settle(() => ({ + snapshot: harness.snapshot(), + hasProceed: proceed !== null, + })); + expect(activeSnapshot(harness.getStack)).toMatchObject({ + name: "Article", + params: { articleId: "2" }, + }); + + await act(async () => { + proceed?.(); + }); + await harness.settle(); + + expect(harness.currentPath()).toBe("/articles/1/"); + expect(activeSnapshot(harness.getStack)).toMatchObject({ + name: "Article", + params: { articleId: "1" }, + }); + }); + + it("navigation started inside a browser-back blocker hook converges URL and stack", async () => { + let actions: Harness["actions"] | null = null; + const onBlocked = jest.fn(() => { + actions?.push("Article", { articleId: "3" }); + }); + const harness = await renderHarness({ + blocker: { + shouldBlock: (action) => action.name === "Popped", + onBlocked, + }, + }); + actions = harness.actions; + await pushArticle(harness, "1"); + await pushArticle(harness, "2"); + + await act(async () => { + harness.history.back(); + }); + await harness.settle(() => ({ + snapshot: harness.snapshot(), + blockedCount: onBlocked.mock.calls.length, + })); + + expect(onBlocked).toHaveBeenCalledTimes(1); + expect(harness.currentPath()).toBe("/articles/3/"); + expect(activeSnapshot(harness.getStack)).toMatchObject({ + name: "Article", + params: { articleId: "3" }, + stepParams: { articleId: "3" }, + }); + }); + }); + + describe("browser history to stackflow state", () => { + it("back and forward converge active activity and step params", async () => { + const harness = await renderHarness(); + await pushArticle(harness, "1"); + await pushArticleStep(harness, { articleId: "1", tab: "comments" }); + await pushArticle(harness, "2"); + + await expectLocationAfterBrowserMove( + harness, + () => harness.history.back(), + { + url: "/articles/1/?tab=comments", + activeName: "Article", + articleId: "1", + tab: "comments", + }, + ); + await expectLocationAfterBrowserMove( + harness, + () => harness.history.back(), + { + url: "/articles/1/", + activeName: "Article", + articleId: "1", + }, + ); + await expectLocationAfterBrowserMove( + harness, + () => harness.history.back(), + { + url: "/home/", + activeName: "Home", + }, + ); + await expectLocationAfterBrowserMove( + harness, + () => harness.history.forward(), + { + url: "/articles/1/", + activeName: "Article", + articleId: "1", + }, + ); + await expectLocationAfterBrowserMove( + harness, + () => harness.history.forward(), + { + url: "/articles/1/?tab=comments", + activeName: "Article", + articleId: "1", + tab: "comments", + }, + ); + await expectLocationAfterBrowserMove( + harness, + () => harness.history.forward(), + { + url: "/articles/2/", + activeName: "Article", + articleId: "2", + }, + ); + }); + + it("go(n) converges through activity entries", async () => { + const harness = await renderHarness(); + await pushArticle(harness, "10"); + await pushArticle(harness, "20"); + await pushArticle(harness, "30"); + + await expectLocationAfterBrowserMove( + harness, + () => harness.history.go(-2), + { + url: "/articles/10/", + activeName: "Article", + articleId: "10", + }, + ); + await expectLocationAfterBrowserMove( + harness, + () => harness.history.go(2), + { + url: "/articles/30/", + activeName: "Article", + articleId: "30", + }, + ); + }); + + it("dispatches queued while paused converge URL and stack after resume", async () => { + const harness = await renderHarness(); + await pushArticle(harness, "1"); + + await act(async () => { + harness.coreActions?.pause(); + }); + await harness.settle(); + expect(harness.snapshot()).toMatchObject({ + url: "/articles/1/", + active: { + name: "Article", + params: { articleId: "1" }, + transition: "paused", + }, + }); + + await act(async () => { + harness.actions.push("Article", { articleId: "2" }); + harness.stepActions.pushStep({ articleId: "22", tab: "queued" }); + }); + await harness.settle(); + expect(harness.snapshot()).toMatchObject({ + url: "/articles/1/", + active: { + name: "Article", + params: { articleId: "1" }, + transition: "paused", + }, + }); + + await act(async () => { + harness.coreActions?.resume(); + }); + await harness.settle(); + + expect(harness.currentPath()).toBe("/articles/22/?tab=queued"); + expect(activeSnapshot(harness.getStack)).toMatchObject({ + name: "Article", + params: { articleId: "22" }, + stepParams: { articleId: "22" }, + transition: "idle", + }); + }); + + it("calls fallbackActivity exactly once through the real unmatched initial route path", async () => { + const fallbackActivity = jest.fn((): "Home" => "Home"); + + const harness = await renderHarness({ + initialPath: "/not-found?from=fallback", + fallbackActivity, + }); + + expect(fallbackActivity).toHaveBeenCalledTimes(1); + expect(fallbackActivity).toHaveBeenCalledWith({ initialContext: {} }); + expect(harness.currentPath()).toBe("/home/?from=fallback"); + expect(activeSnapshot(harness.getStack)).toMatchObject({ + name: "Home", + transition: "idle", + }); + }); + }); + + describe("cross-reload journal acceptance", () => { + it("preserves an observed-only step entry after blocked cross-reload back in journal mode", async () => { + let shouldPreventStepPop = true; + const onBlocked = jest.fn(); + const shouldBlock = jest.fn( + (action: NavigationAction) => + shouldPreventStepPop && action.name === "StepPopped", + ); + const harness = await createReloadedStepBoundaryHarness({ + blocker: { + shouldBlock, + onBlocked, + }, + }); + + expect(harness.snapshot()).toMatchObject({ + url: "/articles/top/?tab=details", + active: { + name: "Article", + params: { articleId: "top", tab: "details" }, + stepParams: { articleId: "top", tab: "details" }, + stepCount: 2, + }, + }); + const beforeBlockedBack = harness.snapshot(); + + await act(async () => { + harness.history.back(); + }); + await harness.settle(() => ({ + snapshot: harness.snapshot(), + blockedCount: onBlocked.mock.calls.length, + })); + + expect(onBlocked).toHaveBeenCalledWith( + expect.objectContaining({ + action: expect.objectContaining({ name: "StepPopped" }), + }), + expect.anything(), + ); + expect(harness.snapshot()).toEqual(beforeBlockedBack); + + shouldPreventStepPop = false; + + await expectLocationAfterBrowserMove( + harness, + () => harness.history.back(), + { + url: "/articles/middle/?tab=comments", + activeName: "Article", + articleId: "middle", + tab: "comments", + }, + ); + expect(activeSnapshot(harness.getStack)).toMatchObject({ + stepParams: { articleId: "middle", tab: "comments" }, + }); + }); + + it("preserves an observed-only step entry after blocked cross-reload back without sessionStorage", async () => { + let shouldPreventStepPop = true; + const onBlocked = jest.fn(); + const harness = await createReloadedStepBoundaryHarness({ + sessionStorage: null, + blocker: { + shouldBlock: (action) => + shouldPreventStepPop && action.name === "StepPopped", + onBlocked, + }, + }); + const beforeBlockedBack = harness.snapshot(); + + await act(async () => { + harness.history.back(); + }); + await harness.settle(() => ({ + snapshot: harness.snapshot(), + blockedCount: onBlocked.mock.calls.length, + })); + + expect(onBlocked).toHaveBeenCalledTimes(1); + expect(harness.snapshot()).toEqual(beforeBlockedBack); + + shouldPreventStepPop = false; + + await expectLocationAfterBrowserMove( + harness, + () => harness.history.back(), + { + url: "/articles/middle/?tab=comments", + activeName: "Article", + articleId: "middle", + tab: "comments", + }, + ); + expect(activeSnapshot(harness.getStack)).toMatchObject({ + stepParams: { articleId: "middle", tab: "comments" }, + }); + }); + + it("reconstructs ancestor stack fidelity after a cross-reload backward go(-n)", async () => { + const harness = await createReloadedActivityChainHarness(); + + await expectLocationAfterBrowserMove( + harness, + () => harness.history.go(-2), + { + url: "/articles/10/", + activeName: "Article", + articleId: "10", + }, + ); + + expect(activeSnapshot(harness.getStack)).toMatchObject({ + activityCount: 2, + }); + + await act(async () => { + harness.actions.pop(); + }); + await harness.settle(); + + expect(harness.currentPath()).toBe("/home/"); + expect(activeSnapshot(harness.getStack)).toMatchObject({ + name: "Home", + }); + }); + + it("reconstructs skipped intermediate entries during cross-reload forward go(+n)", async () => { + const harness = await createReloadedActivityChainHarness(); + + await expectLocationAfterBrowserMove( + harness, + () => harness.history.go(-2), + { + url: "/articles/10/", + activeName: "Article", + articleId: "10", + }, + ); + await expectLocationAfterBrowserMove( + harness, + () => harness.history.go(2), + { + url: "/articles/30/", + activeName: "Article", + articleId: "30", + }, + ); + + expect(activeSnapshot(harness.getStack)).toMatchObject({ + activityCount: 4, + }); + + await act(async () => { + harness.actions.pop(); + }); + await harness.settle(); + + expect(harness.currentPath()).toBe("/articles/20/"); + expect(activeSnapshot(harness.getStack)).toMatchObject({ + name: "Article", + params: { articleId: "20" }, + }); + }); + + it("restores the original entry when a cross-reload multi-entry jump is blocked", async () => { + const onBlocked = jest.fn(); + const harness = await createReloadedActivityChainHarness({ + blocker: { + shouldBlock: (action) => action.name === "Popped", + onBlocked, + }, + }); + const beforeBlockedJump = harness.snapshot(); + + await act(async () => { + harness.history.go(-2); + }); + await harness.settle(() => ({ + snapshot: harness.snapshot(), + blockedCount: onBlocked.mock.calls.length, + })); + + expect(onBlocked).toHaveBeenCalledWith( + expect.objectContaining({ + action: expect.objectContaining({ name: "Popped" }), + }), + expect.anything(), + ); + expect(harness.snapshot()).toEqual(beforeBlockedJump); + }); + + it("restores the original entry when a cross-reload forward multi-entry replay is blocked", async () => { + let shouldPreventForwardPush = false; + const onBlocked = jest.fn(); + const harness = await createReloadedActivityChainHarness({ + blocker: { + shouldBlock: (action) => + shouldPreventForwardPush && action.name === "Pushed", + onBlocked, + }, + }); + + await expectLocationAfterBrowserMove( + harness, + () => harness.history.go(-2), + { + url: "/articles/10/", + activeName: "Article", + articleId: "10", + }, + ); + + const beforeBlockedForwardJump = harness.snapshot(); + shouldPreventForwardPush = true; + + await act(async () => { + harness.history.go(2); + }); + await harness.settle(() => ({ + snapshot: harness.snapshot(), + blockedCount: onBlocked.mock.calls.length, + })); + + expect(onBlocked).toHaveBeenCalledWith( + expect.objectContaining({ + action: expect.objectContaining({ name: "Pushed" }), + }), + expect.anything(), + ); + expect(harness.snapshot()).toEqual(beforeBlockedForwardJump); + }); + + it("keeps a second instance usable on the same browser window and sessionStorage", async () => { + const sessionStorage = makeSessionStorageShim(); + const first = await renderHarness({ sessionStorage }); + await pushArticle(first, "first"); + + const second = await renderHarness({ + browserWindow: first.browserWindow, + baseHistoryLength: first.baseHistoryLength, + sessionStorage, + }); + + expect(second.currentPath()).toBe("/articles/first/"); + expect(activeSnapshot(second.getStack)).toMatchObject({ + name: "Article", + params: { articleId: "first" }, + stepParams: { articleId: "first" }, + }); + + await pushArticle(second, "second"); + + expect(second.currentPath()).toBe("/articles/second/"); + expect(activeSnapshot(second.getStack)).toMatchObject({ + name: "Article", + params: { articleId: "second" }, + stepParams: { articleId: "second" }, + }); + }); + + it("falls back to the current history state when sessionStorage has unrelated data", async () => { + const sessionStorage = makeSessionStorageShim({ + initialEntries: { + "unrelated-history-journal": "not this app instance", + }, + }); + const harness = await renderHarness({ sessionStorage }); + await pushArticle(harness, "stored"); + + const reloaded = await reloadHarness(harness, { sessionStorage }); + + expect(reloaded.currentPath()).toBe("/articles/stored/"); + expect(activeSnapshot(reloaded.getStack)).toMatchObject({ + name: "Article", + params: { articleId: "stored" }, + stepParams: { articleId: "stored" }, + }); + }); + + it("falls back to the current history state when sessionStorage is unavailable", async () => { + const harness = await renderHarness({ sessionStorage: null }); + await pushArticle(harness, "no-storage"); + + const reloaded = await reloadHarness(harness, { sessionStorage: null }); + + expect(reloaded.currentPath()).toBe("/articles/no-storage/"); + expect(activeSnapshot(reloaded.getStack)).toMatchObject({ + name: "Article", + params: { articleId: "no-storage" }, + stepParams: { articleId: "no-storage" }, + }); + }); + + it("falls back to the current history state when accessing sessionStorage throws", async () => { + const harness = await renderHarness({ sessionStorage: "throw" }); + await pushArticle(harness, "blocked-storage-access"); + + const reloaded = await reloadHarness(harness, { + sessionStorage: "throw", + }); + + expect(reloaded.currentPath()).toBe("/articles/blocked-storage-access/"); + expect(activeSnapshot(reloaded.getStack)).toMatchObject({ + name: "Article", + params: { articleId: "blocked-storage-access" }, + stepParams: { articleId: "blocked-storage-access" }, + }); + }); + + it("falls back to the current history state when sessionStorage operations throw", async () => { + const sessionStorage = makeSessionStorageShim({ + faults: ["getItem", "setItem", "removeItem", "clear"], + }); + const harness = await renderHarness({ sessionStorage }); + await pushArticle(harness, "faulty-storage"); + + const reloaded = await reloadHarness(harness, { sessionStorage }); + + expect(reloaded.currentPath()).toBe("/articles/faulty-storage/"); + expect(activeSnapshot(reloaded.getStack)).toMatchObject({ + name: "Article", + params: { articleId: "faulty-storage" }, + stepParams: { articleId: "faulty-storage" }, + }); + }); + }); + + describe("stackflow actions to browser history", () => { + it("push, replace, and pop update URL and preserve observable browser entries", async () => { + const harness = await renderHarness(); + + await pushArticle(harness, "1"); + expect(harness.snapshot()).toMatchObject({ + url: "/articles/1/", + historyLengthDelta: 1, + }); + + await act(async () => { + harness.actions.replace("Article", { articleId: "2" }); + }); + await harness.settle(); + expect(harness.snapshot()).toMatchObject({ + url: "/articles/2/", + historyLengthDelta: 1, + }); + + await pushArticle(harness, "3"); + expect(harness.snapshot()).toMatchObject({ + url: "/articles/3/", + historyLengthDelta: 2, + }); + + await act(async () => { + harness.actions.pop(); + }); + await harness.settle(); + expect(harness.snapshot()).toMatchObject({ + url: "/articles/2/", + historyLengthDelta: 2, + }); + }); + + it("stepPush, stepReplace, and stepPop update URL while keeping stack state reloadable from history", async () => { + const harness = await renderHarness(); + await pushArticle(harness, "10"); + + await pushArticleStep(harness, { articleId: "11", tab: "comments" }); + expect(harness.snapshot()).toMatchObject({ + url: "/articles/11/?tab=comments", + historyLengthDelta: 2, + }); + + await act(async () => { + harness.stepActions.replaceStep({ articleId: "12", tab: "details" }); + }); + await harness.settle(); + expect(harness.snapshot()).toMatchObject({ + url: "/articles/12/?tab=details", + historyLengthDelta: 2, + }); + + await act(async () => { + harness.stepActions.popStep(); + }); + await harness.settle(); + expect(harness.snapshot()).toMatchObject({ + url: "/articles/10/", + historyLengthDelta: 2, + }); + + await expectLocationAfterBrowserMove( + harness, + () => harness.history.forward(), + { + url: "/articles/12/?tab=details", + activeName: "Article", + articleId: "12", + tab: "details", + }, + ); + }); + + it("push after browser back truncates the stale forward branch", async () => { + const harness = await renderHarness(); + await pushArticle(harness, "10"); + await pushArticle(harness, "20"); + await pushArticle(harness, "30"); + + await act(async () => { + harness.history.back(); + }); + await harness.settle(); + await act(async () => { + harness.history.back(); + }); + await harness.settle(); + + expect(harness.snapshot()).toMatchObject({ + url: "/articles/10/", + historyLengthDelta: 3, + active: { name: "Article", params: { articleId: "10" } }, + }); + + await pushArticle(harness, "99"); + + expect(harness.snapshot()).toMatchObject({ + url: "/articles/99/", + historyLengthDelta: 2, + active: { + name: "Article", + params: { articleId: "99" }, + activityCount: 3, + }, + }); + + const afterPush = harness.snapshot(); + + await act(async () => { + harness.history.forward(); + }); + await harness.settle(); + + expect(harness.snapshot()).toEqual(afterPush); + }); + + it("replace that shrinks step entries truncates the stale forward branch", async () => { + const harness = await renderHarness(); + await pushArticle(harness, "10"); + await pushArticleStep(harness, { articleId: "11", tab: "one" }); + await pushArticleStep(harness, { articleId: "12", tab: "two" }); + + expect(harness.snapshot()).toMatchObject({ + url: "/articles/12/?tab=two", + historyLengthDelta: 3, + }); + + await act(async () => { + harness.actions.replace("Article", { articleId: "99" }); + }); + await harness.settle(); + + expect(harness.snapshot()).toMatchObject({ + url: "/articles/99/", + historyLengthDelta: 1, + active: { + name: "Article", + params: { articleId: "99" }, + activityCount: 2, + }, + }); + + const afterReplace = harness.snapshot(); + + await act(async () => { + harness.history.forward(); + }); + await harness.settle(); + + expect(harness.snapshot()).toEqual(afterReplace); + }); + + it("in-place replace (reused activityId) rewrites only its own entry and keeps ancestors", async () => { + const harness = await renderHarness(); + + let activityId = ""; + await act(async () => { + activityId = harness.actions.push("Article", { + articleId: "10", + }).activityId; + }); + await harness.settle(); + + expect(harness.snapshot()).toMatchObject({ + url: "/articles/10/", + historyLengthDelta: 1, + }); + + await act(async () => { + harness.actions.replace("Article", { articleId: "99" }, { activityId }); + }); + await harness.settle(); + + expect(harness.snapshot()).toMatchObject({ + url: "/articles/99/", + historyLengthDelta: 1, + active: { + name: "Article", + params: { articleId: "99" }, + activityCount: 2, + }, + }); + + await expectLocationAfterBrowserMove( + harness, + () => harness.history.back(), + { + url: "/home/", + activeName: "Home", + }, + ); + }); + }); +}); diff --git a/extensions/plugin-history-sync/src/historySyncPlugin.tsx b/extensions/plugin-history-sync/src/historySyncPlugin.tsx index fab01059f..b16ed2244 100644 --- a/extensions/plugin-history-sync/src/historySyncPlugin.tsx +++ b/extensions/plugin-history-sync/src/historySyncPlugin.tsx @@ -4,25 +4,32 @@ import type { RegisteredActivityName, } from "@stackflow/config"; import { + type Activity, + type ActivityStep, id, type PushedEvent, type Stack, type StackflowActions, type StepPushedEvent, } from "@stackflow/core"; -import type { StackflowReactPlugin } from "@stackflow/react"; -import type { ActivityComponentType } from "@stackflow/react"; -import type { History, Listener } from "history"; +import type { + ActivityComponentType, + StackflowReactPlugin, +} from "@stackflow/react"; +import type { History } from "history"; import { createBrowserHistory, createMemoryHistory } from "history"; import { useEffect, useSyncExternalStore } from "react"; import UrlPattern from "url-pattern"; import { ActivityActivationCountsContext } from "./ActivityActivationCountsContext"; import type { ActivityActivationMonitor } from "./ActivityActivationMonitor/ActivityActivationMonitor"; import { DefaultHistoryActivityActivationMonitor } from "./ActivityActivationMonitor/DefaultHistoryActivityActivationMonitor"; +import { identityOfState } from "./BrowserHistoryEntryModel"; +import { computeDesiredHistoryEntries } from "./desiredHistoryEntries"; +import { HistoryEntryJournal } from "./HistoryEntryJournal"; import { HistoryQueueProvider } from "./HistoryQueueContext"; -import { parseState, pushState, replaceState } from "./historyState"; +import { HistoryReconciler } from "./HistoryReconciler"; +import { getStateStepId, parseState, type State } 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"; @@ -68,6 +75,50 @@ type HistorySyncPluginOptions> = ( urlPatternOptions?: UrlPatternOptions; }; +/** + * Defensive bound on navigation dispatch loops driven by a single popstate. + * A loop that does not shrink the stack (the core refused an event the model + * predicted it would accept) must bail out instead of spinning; the follow-up + * reconcile pass restores consistency. + */ +const MAX_NAVIGATION_DISPATCHES = 100; + +function isEnteredActivity(activity: Activity): boolean { + return ( + activity.transitionState === "enter-active" || + activity.transitionState === "enter-done" + ); +} + +/** + * Entered activities in navigation order (`enteredBy.eventDate`). Note that + * the core's `isActive`/render order follows the `activities` array slot + * order instead; the two agree for every navigation this plugin dispatches — + * see the ordering note in `desiredHistoryEntries.ts`. + */ +function enteredActivitiesOf(stack: Stack): Activity[] { + return stack.activities + .filter(isEnteredActivity) + .sort((a, b) => a.enteredBy.eventDate - b.enteredBy.eventDate); +} + +function activeActivityOf(stack: Stack): Activity | undefined { + return last(enteredActivitiesOf(stack)); +} + +function liveStepsOf(activity: Activity): ActivityStep[] { + return activity.steps.filter((step) => !step.exitedBy); +} + +function isStepEnteredBy( + step: ActivityStep, +): step is ActivityStep & { enteredBy: StepPushedEvent } { + return ( + step.enteredBy.name === "StepPushed" || + step.enteredBy.name === "StepReplaced" + ); +} + export function historySyncPlugin< T extends { [activityName: string]: unknown }, K extends Extract, @@ -100,14 +151,10 @@ export function historySyncPlugin< const activityRoutes = sortActivityRoutes(normalizeActivityRouteMap(routes)); return () => { - let pushFlag = 0; - let silentFlag = false; let initialSetupProcess: NavigationProcess | null = null; const activityActivationMonitors: ActivityActivationMonitor[] = []; const activityActivationCountsChangeNotifier = new Publisher(); - const { requestHistoryTick } = makeHistoryTaskQueue(history); - const subscribeActivityActivationCountsChange = ( subscriber: () => void, ) => { @@ -211,6 +258,550 @@ export function historySyncPlugin< runActivityActivationMonitors(stack); }; + const computeDesired = () => { + if (!coreActions) { + return []; + } + + return computeDesiredHistoryEntries({ + stack: coreActions.getStack(), + activityRoutes, + urlPatternOptions: options.urlPatternOptions, + }); + }; + + const journal = new HistoryEntryJournal({ + // Memory histories (SSR, tests) have no tab session to journal into; + // `index` is the one field history v5 exposes on memory histories + // only. `sessionStorage` shares its lifetime with the tab's browser + // history, which is exactly the journal's validity window. The + // property access is evaluated lazily inside the journal so that + // environments where it throws (e.g. hardened iframes) degrade to a + // no-op instead of crashing. + enabled: !("index" in history) && typeof window !== "undefined", + getStorage: () => + typeof window === "undefined" + ? undefined + : (window.sessionStorage ?? undefined), + }); + + const reconciler = new HistoryReconciler({ + history, + useHash: options.useHash, + computeDesired, + onExternalPopState: (state) => handleExternalPopState(state), + journal, + }); + + /** + * Dispatches a navigation action and reports whether it actually reached + * the core (i.e. was not prevented by a pre-effect hook). The check works + * by observing the recorded event log: every checked dispatch here uses a + * fresh event date, so an accepted event always lands at the end of the + * sorted log. Re-entrant dispatches made by other plugins' hooks (e.g. a + * blocker pushing inside `onBlocked`) append events of *different* names + * and do not produce false positives. + * + * Note: while the stack is paused, the core defers events instead of + * recording them, so dispatches during pause read as "prevented". That is + * the intended behavior — a frozen stack must not be navigated, and the + * follow-up reconcile pass restores the browser to the frozen stack. + */ + const dispatchChecked = ( + actions: StackflowActions, + eventName: string, + dispatch: () => void, + ): boolean => { + const eventCountBefore = actions.getStack().events.length; + + dispatch(); + + return actions + .getStack() + .events.slice(eventCountBefore) + .some((event) => event.name === eventName); + }; + + /** + * Resolves the absolute entry index a popstate landed on. New-format + * states carry it explicitly; states serialized by older plugin versions + * fall back to the legacy direction inference (compare the target + * activity/step against the active one) and assume a single-entry move. + */ + const resolveEntryIndex = ( + state: State, + fromIndex: number, + actions: StackflowActions, + ): number => { + if (typeof state.entryIndex === "number") { + return state.entryIndex; + } + + const active = activeActivityOf(actions.getStack()); + + if (!active) { + return fromIndex; + } + + if (state.activity.id !== active.id) { + return state.activity.id < active.id ? fromIndex - 1 : fromIndex + 1; + } + + if (!state.step) { + return fromIndex - 1; + } + + const currentStep = last(liveStepsOf(active)); + + if (!currentStep || currentStep.id === state.step.id) { + return fromIndex; + } + + return state.step.id < currentStep.id ? fromIndex - 1 : fromIndex + 1; + }; + + /** + * The contiguous run of model-known entry states ending at `toIndex`, in + * ascending index order. After a backward jump beyond this session's + * stack, this is the chain of ancestor entries that can be restored with + * historical fidelity: with a validated journal it spans the previous + * sessions' entries, without one it degrades to just the landing entry + * (which the surrounding popstate handler always learns first). + */ + const collectKnownChainEndingAt = (toIndex: number): State[] => { + const chain: State[] = []; + + for (let index = toIndex; ; index--) { + const entry = reconciler.model.getEntry(index); + + if (!entry) { + break; + } + + chain.push(entry.state); + } + + return chain.reverse(); + }; + + /** + * Backward navigation: pop down to the target entry through the formal + * action path, so that every plugin's pre-effect hooks (including + * `preventDefault`) participate. If any pop is prevented the dispatch + * loop stops immediately — the unconditional reconcile pass that follows + * every popstate then restores the browser to the (unchanged) stack. + * + * Entries the stack no longer knows (written before a reload) are + * restored by re-dispatching their original entry events: `makeEvent` + * preserves the snapshot's `id`/`eventDate`, so the events re-aggregate + * at their historical position and the trailing pop events settle the + * stack exactly on the target entry. + */ + const handleBackwardNavigation = ( + state: State, + toIndex: number, + actions: StackflowActions, + ): boolean => { + const targetActivityId = state.activity.id; + const targetStepId = getStateStepId(state); + const isTargetActivityEntered = enteredActivitiesOf( + actions.getStack(), + ).some((activity) => activity.id === targetActivityId); + + if (!isTargetActivityEntered) { + // The landing entry predates this session's stack, so every entered + // activity lies above it and leaves through a formal (preventable) + // pop. The final pop on the root is a recorded no-op that settles + // against the restored ancestors during re-aggregation: the replayed + // chain re-enters at its historical position (earlier event dates), + // so the now-dated pop events exit exactly the activities above the + // landing entry. + const activitiesToPop = enteredActivitiesOf(actions.getStack()).length; + + for ( + let i = 0; + i < activitiesToPop && i < MAX_NAVIGATION_DISPATCHES; + i++ + ) { + if (!dispatchChecked(actions, "Popped", () => actions.pop())) { + return false; + } + } + + // Restore the known ancestor chain — not just the landing snapshot — + // so that back/forward granularity around the landing entry survives + // the jump (journal mode); without journal knowledge the chain is + // the landing entry alone. + const knownEventIds = new Set( + actions.getStack().events.map((event) => event.id), + ); + const chain = collectKnownChainEndingAt(toIndex); + const replayedActivityEventIds = new Set(); + + for (const entryState of chain) { + const activityEnteredBy = entryState.activity.enteredBy; + + if ( + !replayedActivityEventIds.has(activityEnteredBy.id) && + !knownEventIds.has(activityEnteredBy.id) + ) { + replayedActivityEventIds.add(activityEnteredBy.id); + actions.dispatchEvent("Pushed", { + ...activityEnteredBy, + }); + } + + if ( + entryState.step && + isStepEnteredBy(entryState.step) && + !knownEventIds.has(entryState.step.enteredBy.id) + ) { + actions.dispatchEvent("StepPushed", { + ...entryState.step.enteredBy, + }); + } + } + + // Materialize slots for known activities *ahead* of the landing + // entry as well (historical enter + immediate transition-less exit). + // The core derives the active activity from the reducer's slot + // order, and a later forward replay re-enters an exited activity in + // its existing slot — without these slots a forward replay that + // appends a previous-session intermediate would interleave the slot + // order against navigation order and activate the wrong activity. + let materializedSlotCount = 0; + + for (let index = toIndex + 1; ; index++) { + const entry = reconciler.model.getEntry(index); + + if (!entry) { + break; + } + + if (entry.state.step) { + // Step entries live inside their activity's slot. + continue; + } + + const activityEnteredBy = entry.state.activity.enteredBy; + + if ( + replayedActivityEventIds.has(activityEnteredBy.id) || + knownEventIds.has(activityEnteredBy.id) + ) { + continue; + } + + replayedActivityEventIds.add(activityEnteredBy.id); + actions.dispatchEvent("Pushed", { + ...activityEnteredBy, + }); + materializedSlotCount += 1; + } + + for (let i = 0; i < materializedSlotCount; i++) { + actions.dispatchEvent("Popped", { + skipExitActiveState: true, + }); + } + + // The replay reconstructs state outside the formal action path, so + // double-check it actually settled on the landing activity before + // reporting the navigation complete (anchor re-derivation depends on + // it); the follow-up reconcile pass restores the browser otherwise. + return activeActivityOf(actions.getStack())?.id === targetActivityId; + } + + for (let i = 0; i <= MAX_NAVIGATION_DISPATCHES; i++) { + if (i === MAX_NAVIGATION_DISPATCHES) { + // Exhausting the bound means the stack keeps changing faster than + // we pop towards the target — abnormal, even though the follow-up + // reconcile pass converges the browser either way. + console.error( + `[plugin-history-sync] backward navigation did not reach the target activity within ${MAX_NAVIGATION_DISPATCHES} pops; converging via reconciliation instead`, + ); + return false; + } + + const active = activeActivityOf(actions.getStack()); + + if (!active || active.id === targetActivityId) { + break; + } + + if (!dispatchChecked(actions, "Popped", () => actions.pop())) { + return false; + } + + if (activeActivityOf(actions.getStack())?.id === active.id) { + // The core refused to pop further (e.g. only the root is left + // while the model predicted more entries) — bail out and let the + // reconcile pass converge. + return false; + } + } + + const active = activeActivityOf(actions.getStack()); + + if (!active || active.id !== targetActivityId) { + return false; + } + + if (liveStepsOf(active).some((step) => step.id === targetStepId)) { + for (let i = 0; i <= MAX_NAVIGATION_DISPATCHES; i++) { + if (i === MAX_NAVIGATION_DISPATCHES) { + console.error( + `[plugin-history-sync] backward navigation did not reach the target step within ${MAX_NAVIGATION_DISPATCHES} step pops; converging via reconciliation instead`, + ); + return false; + } + + const currentActive = activeActivityOf(actions.getStack()); + + if (!currentActive) { + return false; + } + + const liveSteps = liveStepsOf(currentActive); + + if (last(liveSteps)?.id === targetStepId) { + break; + } + + if ( + !dispatchChecked(actions, "StepPopped", () => actions.stepPop()) + ) { + return false; + } + + if ( + liveStepsOf(activeActivityOf(actions.getStack())!).length === + liveSteps.length + ) { + return false; + } + } + + return true; + } + + // The target step is not part of the current stack (its entry predates + // a reload): pop the entries above it, then restore it at its + // historical position. The entry-distance estimate overcounts when + // steps lost on restore sit between the target and the live steps (the + // boot mapping is a fiction there), so it is clamped to the steps that + // can actually leave — the activity's first step never pops. + const liveStepCount = liveStepsOf(active).length; + const stepsToPop = Math.min( + computeDesired().length - 1 - (toIndex - reconciler.model.anchorIndex), + liveStepCount - 1, + ); + + for (let i = 0; i < stepsToPop; i++) { + const activeBefore = activeActivityOf(actions.getStack()); + + if (!activeBefore) { + return false; + } + + const liveStepCountBefore = liveStepsOf(activeBefore).length; + + if (!dispatchChecked(actions, "StepPopped", () => actions.stepPop())) { + return false; + } + + const activeAfter = activeActivityOf(actions.getStack()); + + if ( + !activeAfter || + liveStepsOf(activeAfter).length === liveStepCountBefore + ) { + // The core refused the step pop (a recorded no-op — e.g. only one + // live step left while the entry distance predicted more). Treating + // it as progress would land the restoration on the wrong step. + return false; + } + } + + // Restore the known step chain of this activity up to the landing + // entry — with a validated journal this includes intermediate steps + // skipped over by a multi-entry jump; without one it is the landing + // step alone (learned from the popstate). + const remainingActive = activeActivityOf(actions.getStack()); + const remainingStepIds = new Set( + remainingActive + ? liveStepsOf(remainingActive).map((step) => step.id) + : [], + ); + const stepStatesToReplay: State[] = []; + + for (let index = toIndex; ; index--) { + const entry = reconciler.model.getEntry(index); + const entryStep = entry?.state.step; + + if ( + !entry || + entry.state.activity.id !== targetActivityId || + !entryStep || + !isStepEnteredBy(entryStep) || + remainingStepIds.has(entryStep.id) + ) { + break; + } + + stepStatesToReplay.push(entry.state); + } + + for (const stepState of stepStatesToReplay.reverse()) { + if (stepState.step && isStepEnteredBy(stepState.step)) { + actions.dispatchEvent("StepPushed", { + ...stepState.step.enteredBy, + }); + } + } + + return true; + }; + + /** + * Forward navigation: re-enter the target entry (and any known + * intermediate entries skipped over by a multi-entry jump) through the + * formal action path. Forward entries always reference activities/steps + * the stack has already popped, so they are re-pushed as fresh events + * that keep the original `activityId`/`stepId` — entry identity stays + * stable while the event lands at the end of the log (a re-dispatch of + * the original event would be deduplicated away). + */ + const handleForwardNavigation = ( + state: State, + fromIndex: number, + toIndex: number, + actions: StackflowActions, + ): boolean => { + for (let index = fromIndex + 1; index <= toIndex; index++) { + const entryState = + index === toIndex ? state : reconciler.model.getEntry(index)?.state; + + if (!entryState) { + // Unknown intermediate entry (previous session) — skip it; its own + // popstate will restore it if the user ever lands on it. + continue; + } + + const active = activeActivityOf(actions.getStack()); + + if (!active) { + return false; + } + + const entryActivityId = entryState.activity.id; + const entryStepId = getStateStepId(entryState); + + if (active.id === entryActivityId) { + if (liveStepsOf(active).some((step) => step.id === entryStepId)) { + continue; + } + + const stepParams = + entryState.step?.params ?? entryState.activity.params; + + if ( + !dispatchChecked(actions, "StepPushed", () => + actions.stepPush({ + stepId: entryStepId, + stepParams, + }), + ) + ) { + return false; + } + + continue; + } + + if ( + !dispatchChecked(actions, "Pushed", () => + actions.push({ + activityId: entryActivityId, + activityName: entryState.activity.name, + activityParams: entryState.activity.params, + }), + ) + ) { + return false; + } + + const entryStep = entryState.step; + + if (entryStep) { + if ( + !dispatchChecked(actions, "StepPushed", () => + actions.stepPush({ + stepId: entryStep.id, + stepParams: entryStep.params, + }), + ) + ) { + return false; + } + } + } + + return true; + }; + + /** + * Interprets a popstate the reconciler did not cause itself (browser + * back/forward/go or a restored entry) and translates it into formal + * navigation actions. The reconciler requests a reconcile pass after this + * handler returns, no matter what was (or was not) dispatched. + */ + const handleExternalPopState = (state: State | null) => { + const actions = coreActions; + + if (!actions) { + return; + } + + if (!state) { + // The cursor left the app's entries (e.g. back past the first app + // entry, where a real browser would unload the page). There is + // nothing to navigate to; reconciliation stays suspended until a + // popstate brings the cursor back onto an app entry. + reconciler.model.markOutOfApp(); + return; + } + + const model = reconciler.model; + const fromIndex = model.currentIndex; + const toIndex = resolveEntryIndex(state, fromIndex, actions); + + model.learnEntry(toIndex, { + identity: identityOfState(state), + state, + }); + model.moveCursor(toIndex); + + if (toIndex === fromIndex) { + // Rapid successive navigations can coalesce: the browser reports the + // same final entry more than once. The first event already + // dispatched the navigation. + return; + } + + const completed = + toIndex < fromIndex + ? handleBackwardNavigation(state, toIndex, actions) + : handleForwardNavigation(state, fromIndex, toIndex, actions); + + if (completed && toIndex < fromIndex) { + // The desired entries now end at the landed entry; re-derive the + // anchor so that entries restored from previous sessions extend the + // coordinate system downwards. + model.setAnchorIndex(toIndex - (computeDesired().length - 1)); + } + }; + return { key: "plugin-history-sync", wrapStack({ stack }) { @@ -237,8 +828,23 @@ export function historySyncPlugin< dispatchInitialSetupNavigation(coreActions); }, []); + /** + * Ties the reconciler's history listener to the `` + * lifecycle so unmounting the app stops it from interpreting + * popstates (and fixes the listener leak of previous versions). + */ + useEffect(() => { + reconciler.retain(); + + return () => { + reconciler.release(); + }; + }, []); + return ( - + r.activityName === activity.name, - )!; - const template = makeTemplate(match, options.urlPatternOptions); - - if (activity.isRoot) { - replaceState({ - history, - pathname: template.fill(activity.params), - state: { - activity: activity, - }, - useHash: options.useHash, - }); - } else { - pushState({ - history, - pathname: template.fill(activity.params), - state: { - activity: activity, - }, - useHash: options.useHash, - }); - } - - for (const step of activity.steps) { - if (!step.exitedBy && step.enteredBy.name !== "Pushed") { - pushState({ - history, - pathname: template.fill(step.params), - state: { - activity: activity, - step: step, - }, - useHash: options.useHash, - }); - } - } - } - } - } - - const onPopState: Listener = (e) => { - if (silentFlag) { - silentFlag = false; - return; - } - - const state = parseState(e.location.state); - - if (!state) { - return; - } - - const targetActivity = state.activity; - const targetActivityId = state.activity.id; - const targetStep = state.step; - - const { activities } = getStack(); - const currentActivity = activities.find( - (activity) => activity.isActive, - ); - - if (!currentActivity) { - return; - } - - const currentStep = last(currentActivity.steps); - - const nextActivity = activities.find( - (activity) => activity.id === targetActivityId, - ); - const nextStep = currentActivity.steps.find( - (step) => step.id === targetStep?.id, - ); - - const isBackward = () => currentActivity.id > targetActivityId; - const isForward = () => currentActivity.id < targetActivityId; - const isStep = () => currentActivity.id === targetActivityId; - - const isStepBackward = () => { - if (!isStep()) { - return false; - } - - if (!targetStep) { - return true; - } - if (currentStep && currentStep.id > targetStep.id) { - return true; - } - - return false; - }; - const isStepForward = () => { - if (!isStep()) { - return false; - } - - if (!currentStep) { - return true; - } - if (targetStep && currentStep.id < targetStep.id) { - return true; - } - - return false; - }; - - if (isBackward()) { - dispatchEvent("Popped", {}); - - if (!nextActivity) { - pushFlag += 1; - push({ - ...targetActivity.enteredBy, - }); - - if ( - targetStep?.enteredBy.name === "StepPushed" || - targetStep?.enteredBy.name === "StepReplaced" - ) { - const { enteredBy } = targetStep; - pushFlag += 1; - stepPush({ - ...enteredBy, - }); - } - } - } - if (isStepBackward()) { - if ( - !nextStep && - targetStep && - (targetStep?.enteredBy.name === "StepPushed" || - targetStep?.enteredBy.name === "StepReplaced") - ) { - const { enteredBy } = targetStep; - - pushFlag += 1; - stepPush({ - ...enteredBy, - }); - } - - dispatchEvent("StepPopped", {}); - } - - if (isForward()) { - pushFlag += 1; - push({ - activityId: targetActivity.id, - activityName: targetActivity.name, - activityParams: targetActivity.params, - }); - } - if (isStepForward()) { - if (!targetStep) { - return; - } - - pushFlag += 1; - stepPush({ - stepId: targetStep.id, - stepParams: targetStep.params, - }); - } - }; - - history.listen(onPopState); - }, - onPushed({ effect: { activity } }) { - if (pushFlag) { - pushFlag -= 1; - return; - } - - const match = activityRoutes.find( - (r) => r.activityName === activity.name, - )!; - - const template = makeTemplate(match, options.urlPatternOptions); - - requestHistoryTick(() => { - silentFlag = true; - pushState({ - history, - pathname: template.fill(activity.params), - state: { - activity, - }, - useHash: options.useHash, - }); - }); - }, - onStepPushed({ effect: { activity, step } }) { - if (pushFlag) { - pushFlag -= 1; - return; - } - - const match = activityRoutes.find( - (r) => r.activityName === activity.name, - )!; - - const template = makeTemplate(match, options.urlPatternOptions); - - requestHistoryTick(() => { - silentFlag = true; - pushState({ - history, - pathname: template.fill(activity.params), - state: { - activity, - step, - }, - useHash: options.useHash, - }); - }); - }, - onReplaced({ effect: { activity } }) { - if (!activity.isActive) { - return; - } - - const match = activityRoutes.find( - (r) => r.activityName === activity.name, - )!; - - const template = makeTemplate(match, options.urlPatternOptions); + const initialState = parseState(history.location.state); - requestHistoryTick(() => { - silentFlag = true; - replaceState({ - history, - pathname: template.fill(activity.params), - state: { - activity, - }, - useHash: options.useHash, - }); - }); - }, - onStepReplaced({ effect: { activity, step } }) { - if (!activity.isActive) { - return; + if (initialState === null) { + reconciler.initializeFreshBoot(computeDesired()); + } else { + reconciler.initializeRestored(initialState, computeDesired()); } - const match = activityRoutes.find( - (r) => r.activityName === activity.name, - )!; - - const template = makeTemplate(match, options.urlPatternOptions); - - requestHistoryTick(() => { - silentFlag = true; - replaceState({ - history, - pathname: template.fill(activity.params), - state: { - activity, - step, - }, - useHash: options.useHash, - }); - }); + reconciler.start(); }, onBeforePush({ actionParams, actions: { overrideActionParams } }) { if ( @@ -747,10 +1088,7 @@ export function historySyncPlugin< }); } }, - onBeforeReplace({ - actionParams, - actions: { overrideActionParams, getStack }, - }) { + onBeforeReplace({ actionParams, actions: { overrideActionParams } }) { if ( !actionParams.activityContext || "path" in actionParams.activityContext === false @@ -769,79 +1107,10 @@ export function historySyncPlugin< }, }); } - - const { activities } = getStack(); - const enteredActivities = activities.filter( - (currentActivity) => - currentActivity.transitionState === "enter-active" || - currentActivity.transitionState === "enter-done", - ); - const previousActivity = - enteredActivities.length > 0 - ? enteredActivities[enteredActivities.length - 1] - : null; - - if (previousActivity) { - for (let i = 0; i < previousActivity.steps.length - 1; i += 1) { - requestHistoryTick((resolve) => { - if (!parseState(history.location.state)) { - silentFlag = true; - history.back(); - } else { - resolve(); - } - }); - - requestHistoryTick(() => { - silentFlag = true; - history.back(); - }); - } - } - }, - onBeforeStepPop({ actions: { getStack } }) { - const { activities } = getStack(); - const currentActivity = activities.find( - (activity) => activity.isActive, - ); - - if ((currentActivity?.steps.length ?? 0) > 1) { - requestHistoryTick(() => { - silentFlag = true; - history.back(); - }); - } - }, - onBeforePop({ actions: { getStack } }) { - const { activities } = getStack(); - const currentActivity = activities.find( - (activity) => activity.isActive, - ); - - if (currentActivity) { - const { isRoot, steps } = currentActivity; - - const popCount = isRoot ? 0 : steps.length; - - for (let i = 0; i < popCount; i += 1) { - requestHistoryTick((resolve) => { - if (!parseState(history.location.state)) { - silentFlag = true; - history.back(); - } else { - resolve(); - } - }); - - requestHistoryTick(() => { - silentFlag = true; - history.back(); - }); - } - } }, onChanged({ actions }) { dispatchInitialSetupNavigation(actions); + reconciler.requestReconcile(); }, }; }; diff --git a/yarn.lock b/yarn.lock index 99d920580..4f236bcd4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5790,7 +5790,7 @@ __metadata: languageName: unknown linkType: soft -"@stackflow/plugin-blocker@workspace:extensions/plugin-blocker": +"@stackflow/plugin-blocker@workspace:^, @stackflow/plugin-blocker@workspace:extensions/plugin-blocker": version: 0.0.0-use.local resolution: "@stackflow/plugin-blocker@workspace:extensions/plugin-blocker" dependencies: @@ -5863,6 +5863,7 @@ __metadata: "@stackflow/config": "npm:^2.0.0" "@stackflow/core": "npm:^2.0.1" "@stackflow/esbuild-config": "npm:^1.0.3" + "@stackflow/plugin-blocker": "workspace:^" "@stackflow/plugin-renderer-basic": "npm:^1.1.14" "@stackflow/react": "npm:^2.1.0" "@swc/core": "npm:^1.6.6"