Skip to content

feat(plugin-history-sync): support preventDefault via history reconciliation (FEP-2001)#720

Draft
ENvironmentSet wants to merge 16 commits into
mainfrom
feature/fep-2001-reconciler
Draft

feat(plugin-history-sync): support preventDefault via history reconciliation (FEP-2001)#720
ENvironmentSet wants to merge 16 commits into
mainfrom
feature/fep-2001-reconciler

Conversation

@ENvironmentSet

Copy link
Copy Markdown
Collaborator

무엇을 / 왜

@stackflow/plugin-history-syncpreventDefault와 안전하게 공존하지 못하던 네 가지 문제를 해소합니다. 이로써 plugin-blockerpreventDefault 소비 플러그인과 호환되고, 플러그인 자체가 안정화됩니다. (Linear: FEP-2001)

해소한 네 문제:

  1. 브라우저 뒤로가기 pop을 preventDefault할 수 없음 — backward navigation을 dispatchEvent("Popped")로 직접 발행해 onBeforePop pre-effect 훅을 우회.
  2. 프로그래밍적 pop() 시 history desynconBeforePophistory.back()을 비동기 큐에 등록한 뒤 다른 플러그인이 preventDefault하면 스택은 불변인데 큐의 history.back()은 실행됨(onBeforeStepPop·onBeforeReplace도 동일).
  3. 브라우저 앞으로가기 push가 prevented되면 desync + pushFlag 카운터 오염 — 출처 추적 카운터가 prevented 시 누수되어 이후 정상 push에서 동기화를 건너뛰는 연쇄 desync.
  4. onBeforePop 훅 실행 순서 의존성 — pre-effect 훅의 비가역 부작용이 등록 순서에 의존.

어떻게 (메커니즘)

네 문제는 한 뿌리를 공유합니다 — 플러그인이 브라우저 history를 여러 곳에서(pre-effect 훅, post-effect 훅, 낙관적 카운터) 제각기 만지고, 브라우저 뒤로가기를 액션 경로가 아닌 dispatchEvent로 우회한 것. 이를 reconciler 구조로 뒤집어 브라우저를 "커밋된 스택"의 엄격한 추종자로 만듭니다.

  • 단일 동기화 권위 (HistorySyncController): 브라우저 history를 변경하는 권위를 하나의 동기화 과정으로 모읍니다. history는 커밋된 스택 변화에만 반응합니다(원리 A — 커밋된 effect에만 history를 만진다).
  • 브라우저←스택 단일 방향 (원리 B): 사용자가 일으킨 popstate는 브라우저를 직접 만지지 않고 정상 액션 파이프라인(pop/stepPop/push)으로 번역됩니다. 그래서 다른 플러그인의 onBefore*가 실행되고 preventDefault가 존중됩니다. 막히면 동기화가 브라우저를 복원하고, 커밋되면 동기화가 따라갑니다.
  • 플러그인 소유 ordinal 좌표: 위치·거리·방향은 core 활동 id 순서가 아니라 history state에 stamp한 플러그인 내부 ordinal로 계산합니다(core id는 동일성 매칭에만). id 순서성에 기대지 않습니다.
  • idle-gated 순수·멱등 동기화 패스: delta = stackOrdinal − browserOrdinal로 정착(idle) 시점에만 1회 동기화. 버퍼링되는 비동기 커밋을 자동 흡수하고, 같은 차이엔 무동작.
  • self-induced 억제 토큰: 자기 유발 history.go만 단일 in-flight 토큰으로 1:1 소비하고, 이동 없는 동작엔 토큰을 set하지 않아 누수가 없습니다.

pre-effect 훅에서 비가역 부작용(history.back() 큐잉)·누수 카운터(pushFlag)·우회 경로(dispatchEvent("Popped"))·억제 플래그(silentFlag)를 전부 적출했습니다. 결과적으로 네 문제가 모두 영구 desync에서 정지점마다 자가치유되는 일시 글리치로 범주가 바뀝니다(eventual consistency).

문제별 해소

문제 해소
1 (back veto 불가) dispatchEvent 우회 폐기 → 액션 파이프라인 번역으로 onBeforePop·preventDefault 존중
2 (program pop desync) pre-effect history.back() 큐잉 전량 제거 → prevented 시 커밋 0 → history 무변
3 (forward 카운터 누수) pushFlag 폐기 → 출처는 (스택, 엔트리) ordinal 차이의 귀결, 누수 불가
4 (훅 순서 의존) history 부작용 전량 커밋 후 동기화로 이동 → pre-effect 무부작용 → 등록 순서 독립

범위

  • 수정은 plugin-history-sync에 갇힘: extensions/plugin-history-sync/src/의 4파일(HistorySyncController.ts 신규, historyState.ts, historySyncPlugin.tsx 재작성, historySyncPlugin.spec.ts).
  • @stackflow/core의 이벤트/훅 계약 불변. 공개 이벤트/effect에 새 필드 없음(ordinal은 history state 내부 데이터).
  • 공개 API 비파괴.
  • 페이지 새로고침(reload) 이후 동기화는 범위 제외(후속 과제) — reload 시 엔트리 ordinal 재구축이 별도 문제.

검증 — preventDefault reconciler 하니스 (e2e/)

구현보다 먼저 만든 검증 하니스를 함께 추가합니다 (e2e/, @stackflow/e2e-history-sync-blocker):

  • 실제 Chromium(T1) + jsdom(T2i) 에서, plugin-history-syncplugin-blocker를 모두 적용한 상태로 검증.
  • 87개 테스트 = 네 문제 재현 + preventDefault 소비자 호환 계약 + 동시성/재진입 + 양 플러그인 기존 스위트 케이스 1:1.
  • 관찰 계약: SCREEN(DOM)·URL·STACK(공개 getStack)·NAVIGABILITY·blocker 통보 로그만 단언(내부 좌표 비단언).
  • 미수정 코드에선 네 문제 지점 32개가 red, 이 구현으로 87개 전부 green(독립 재실측으로 결정성 확인).

설계 근거는 plans/fep-2001/(solution-plan · glossary · ADR 0001~0008)에 함께 담았습니다.

Draft 체크리스트 (ready 전)

  • changeset 추가 (@stackflow/plugin-history-sync minor/patch)
  • CI 통과 확인 (e2e 하니스 포함 시 Chromium 설치 step 필요 여부 검토)
  • reload 후속 과제 이슈 분리

Closes FEP-2001

🤖 Generated with Claude Code

https://claude.ai/code/session_011zkdvyQTHayZMSLw5AUn9q

ENvironmentSet and others added 5 commits June 28, 2026 21:53
…n (FEP-2001)

Confirmed solution mechanism for making plugin-history-sync coexist safely
with preventDefault (plugin-blocker compatibility + history-sync stabilization).

Mechanism (planning altitude only — no code-change plan):
- Anchor browser-history mutation to committed stack effects only; pre-effect
  hooks have no observable side effects (kills hook-order dependency).
- Browser strictly follows the settled committed stack; user popstate only
  attempts the matching stack action through the action pipeline (preventDefault
  honored). Single sync authority (publish) is a pure, idempotent function of
  (current stack, current entry).
- Plugin-owned per-entry ordinal for position/distance/direction (no dependency
  on core id ordering). delta = O_stack - O_browser.
- Single serial queue + idle gating + publish coalesce; suppression token
  (silentFlag) blocks user nav while a self-induced op is in flight.
- Correctness guarantee = level-triggered eventual consistency (browser == stack
  at every rest point, zero permanent desync); transient glitch / single-input
  loss under extreme races is accepted.
- Race policy: no direct arbitration — browser follows the core's deterministic
  event replay. Scope: plugin-confined, core unchanged, reload cold-start excluded
  (follow-up).

Resolves problems 1-4 from the issue. Deliverable: solution-plan + glossary + ADRs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FynMwgeussV4PT1LjJYMUz
…P-2001)

A real-browser (T1: jest + playwright library + in-process vite preview) and
jsdom (T2i) safety net proving @stackflow/plugin-history-sync coexists with
preventDefault consumers (@stackflow/plugin-blocker). Both plugins are applied
together; every quiet point asserts browser == stack (SCREEN ⇿ URL ⇿ STACK).

- Dedicated harness app configured entirely by URL knobs
  (order/hash/lazyDelay/block/blockers/blockAsync/probe), instrumented via a
  window.__harness__ bridge that exposes only public observations.
- Driver Abstraction Layer over a real Chromium page; settle is observed via the
  public transition state + a double-stable (≥1 rAF + 1 macrotask) check, never
  slept for.
- 87 cases mapping 1:1 to the verification plan: history-sync baseline (25),
  blocker suite (37, incl. 3 jsdom-integration internal-contract cases), the four
  problems (12), the coexistence contract (6), concurrency/reentrancy (7).
- Runs the plugins' current source (aliased to src), so red on the unfixed
  product is expected and the gate turns green once the product upholds the
  contract. The baseline navigation suite is green, proving the harness models
  the system faithfully.

No product code is modified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016xnbUjkgG4Nrhkoo3VeSm6
…s2-r1)

Address the review's four "witness too weak → false/trivial green" findings;
the suite shape is unchanged (32 red / 55 green, stable) — the added witnesses
pass on the current product where the surrounding case already passed, or sit
after an existing red so they are reached only once the product is fixed.

- HS-07: prove replace did not add a browser entry via navigability
  (browser back leaves the app) in addition to the public stack depth.
- PB-2a: after a blocked pop, prove the back entry is intact
  (cancel/disarm, browser back reaches Home) alongside the fake-forward no-op.
- CC-6: make pop-proceed idempotency observable with a two-level stack
  (one pop lands on Article(1), a duplicate would reach Home) plus a
  browser-back navigability check.
- CX-3: enter the self-induced shrink window positively with waitForNonIdle()
  before injecting the user back (matches the sibling race cases), so the
  suppression-token race is actually exercised rather than trivially green.
- Document the timeout / "navigated away" red mechanism in the affected
  proceed/async cases.

No product code is modified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016xnbUjkgG4Nrhkoo3VeSm6
Rewrite history↔stack synchronization so browser history is mutated only
in reaction to committed stack changes, never from a pre-effect hook. This
resolves four permanent desyncs with preventDefault-consuming plugins
(see plans/fep-2001/solution-plan.md).

A single authority — HistorySyncController — owns every browser mutation.
Each entry carries a plugin-owned ordinal in its history state; a pure,
idempotent sync pass compares the stack ordinal to the browser ordinal and
pushes, steps back, replaces, or does nothing. It runs only at idle and
coalesces reservations; a single in-flight suppression token keeps the one
self-induced backward move from being read as a user navigation, and is
never taken for a move that does not happen. A user popstate is translated
into the matching pipeline action (pop/stepPop/push/stepPush) so other
plugins' onBefore* hooks run and a preventDefault is honored; the next sync
pass restores the browser when the attempt does not commit.

Removed: the pre-effect history.back side effects (the source of the
order-dependent and program-pop desyncs), the optimistic push-origin
counter (the source of the leaked-counter chain desync), and the
dispatchEvent path for browser back (which bypassed the hooks). The
pre-effect hooks now do only idempotent param normalization.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pv6kvesDjZJDMDqf8wz1d5
…reload

The jsdom navigation suite settled with a fixed delay calibrated to the old
synchronous mechanism. The committed-effect sync converges on the idle
tick, whose wall-clock timing drifts under other tests' lingering
transition timers, so a fixed wait raced that drift. Settle by polling for
the stack to actually reach idle — the same quiet-point contract the
browser harness uses. Assertions are unchanged.

Skip the post-reload history-manipulation case: synchronizing the browser
with the stack after a page reload is out of scope (see
plans/fep-2001/solution-plan.md and
adr/0008-scope-plugin-confined-reload-excluded.md) — only the current entry
is observable on reload, so pre-reload entry ordinals cannot be
reconstructed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pv6kvesDjZJDMDqf8wz1d5
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 29, 2026

Copy link
Copy Markdown

Deploying stackflow-demo with  Cloudflare Pages  Cloudflare Pages

Latest commit: 9360e09
Status: ✅  Deploy successful!
Preview URL: https://2871e86a.stackflow-demo.pages.dev
Branch Preview URL: https://feature-fep-2001-reconciler.stackflow-demo.pages.dev

View logs

@changeset-bot

changeset-bot Bot commented Jun 29, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 9360e09

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 29, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Preview URL Updated (UTC)
✅ Deployment successful!
View logs
stackflow-docs 9360e09 Commit Preview URL Jun 30 2026, 10:29 AM

@pkg-pr-new

pkg-pr-new Bot commented Jun 29, 2026

Copy link
Copy Markdown
  • @stackflow/demo

    yarn add https://pkg.pr.new/@stackflow/link@720.tgz
    
    yarn add https://pkg.pr.new/@stackflow/plugin-history-sync@720.tgz
    

commit: 9360e09

@coderabbitai

coderabbitai Bot commented Jun 29, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 54b46e59-dea5-4547-9853-62549ac573c7

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/fep-2001-reconciler

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Comment thread extensions/plugin-history-sync/src/historyState.ts Outdated
* sync pass uses to decide direction and distance. See the solution plan's
* "entry ordinal".
*/
ordinal?: number;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ordinal이 optional인 이유가 궁금해요.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ordinal역직렬화 경계에서만 optional입니다. parseStatehistory.state의 임의 값을 읽는데, (a) reload 이후나 (b) 이 플러그인이 발행하지 않은(외부) 엔트리에는 ordinal이 없을 수 있습니다. 그 "미stamp" 상태를 readBrowserOrdinalnull로 환원해 컨트롤러가 인지합니다(syncPass 내부에서 불변식 위반이면 이제 throw로 승격 — 위 B7/B8). 반면 컨트롤러가 직접 stamp하는 모든 엔트리는 항상 ordinal을 싣습니다.

즉 우리 쓰기 경로에선 필수이고, 외부 입력을 받는 타입 경계에서만 optional입니다. (useHash를 생성 시 default로 normalize한 것과 같은 결입니다 — optional은 경계에서 흡수.)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parseState가 읽을 때, 대상이 외부 엔트리면 isSerializedState에서 걸러지지 않나요? 내려와도 null이 내려오지, ordinal이 없는 state가 내려오지는 않을 것 같습니다. Reload가 있던 ordinal을 없애지도 않고요. 우리가 write 할 때 ordinal 안 넣는 경우 없으니 required로 둡시다.

Comment thread extensions/plugin-history-sync/src/historyState.ts Outdated
activityName: string;
activityParams: { [key: string]: string | undefined };
}) => void;
stepPush: (params: {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stackflow version 2에서는 pushStep으로 이름을 바꾸었습니다. 참고

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

b074101b로 v2 액션명(pushStep/popStep)에 맞춰 컨트롤러 인터페이스를 개명했습니다. core 액션명은 그대로 두고 adapter에서 매핑합니다. b074101

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stepPush 명명은 core의 Actions 인터페이스를 따라간 거였군요? b074101b는 리버트합시다.

stepParams: { [key: string]: string | undefined };
}) => void;
pop: () => void;
stepPop: () => void;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stackflow version 2에서는 popStep으로 이름을 바꾸었습니다. 참고

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

b074101b로 v2 액션명(pushStep/popStep)에 맞춰 컨트롤러 인터페이스를 개명했습니다. core 액션명은 그대로 두고 adapter에서 매핑합니다. b074101

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stepPop 명명은 core의 Actions 인터페이스를 따라간 거였군요? b074101b는 리버트합시다.

Comment thread extensions/plugin-history-sync/src/HistorySyncController.ts Outdated
if (!targetState || typeof targetState.ordinal !== "number") {
// Navigated to an entry this plugin did not stamp (e.g. below the bottom
// app entry); there is nothing to translate.
return;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

예외 발생시키세요

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 지점은 onHistoryUpdate에서 사용자 popstate가 앱 최하단 엔트리 아래로 이동한 경우(= SPA 밖으로 뒤로가기) 도달하는 정상 경로입니다 — 도착 엔트리에는 이 플러그인이 stamp하지 않은(외부) state가 실려 있습니다. 여기서 throw하면 정상적인 "앱 밖으로 뒤로가기"가 예외로 깨집니다. 그래서 graceful return을 유지합니다.

불변식 위반으로 throw하는 곳은 syncPass 내부(이번에 추가한 B7/B8 — 우리가 stamp한 엔트리여야 하는데 아닌 경우 = 버그)이고, 여기 popstate 경계(앱 이탈 = 정상)와는 구분합니다.

}

const to = targetState.ordinal;
const movement = to - this.browserCursor;

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분 접근 방법이 두 가지인데, 각각 장단점 논의해봅시다.

  1. 지금처럼 history 객체 상에서 이동 거리 계산해서 이에 맞추어 stack navigation 수행
  2. 사용자가 history 객체 상에서 land한 targetState를 stack에서 찾아서 거기까지 이동

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

접근1 (현재): history 상의 이동거리(movement)로 스택을 같은 양만큼 이동.

  • 장점: race에 강함 — 스택이 전이 중이어도 새 ordinal − 직전 ordinal이 사용자 의도(뒤/앞·칸수)를 그대로 반영; 도착 대상이 현재 스택에 존재할 필요 없음; 카운트 기반이라 단순.
  • 단점: 직전 위치 캐시(browserCursor)가 필요; 다단 forward는 단일 popstate에 최종 엔트리 state만 실려 중간 엔트리 재구성은 정지점 수렴으로 흡수.

접근2: 도착 targetState를 스택에서 찾아 거기까지 이동.

  • 장점: 캐시 불요(자기완결).
  • 단점: race 취약 — 전이 중엔 대상이 스택에 아직/이미 없을 수 있고, 도착 ordinal을 현재 stackOrdinal과 절대비교하면 방향을 오판(초기 설계에서 실패해 movement로 바꾼 지점); 동일성 탐색·치환/제거된 대상 모호성 추가.

권고: 접근1 유지. eventual-consistency·무중재 합성 모델과 정합하고, 동시성 테스트(self-induced shrink 창에 사용자 back 주입)가 이를 결정적으로 입증합니다. browserCursor는 저렴하고 매 sync에서 자가 교정됩니다.

Comment thread extensions/plugin-history-sync/src/historySyncPlugin.tsx Outdated
Comment on lines +298 to +310
if (targetState.step && active && targetState.activity.id === active.id) {
this.actions.stepPush({
stepId: targetState.step.id,
stepParams: targetState.step.params,
});
} else {
this.actions.push({
activityId: targetState.activity.id,
activityName: targetState.activity.name,
activityParams: targetState.activity.params,
});
}
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Activity push -> pop -> browser forward 하면 getStack()으로 가져온 스택의 activities 필드 상태가 어떻게 되나요? 1) exited activity가 살아 돌아온다? 2) id가 동일한 activity가 하나 더 생긴다?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(2)번이 아니라 (1)에 가깝습니다 — 같은 id 활동이 제자리에서 부활하고, 중복 생성되지 않습니다.

근거(core): pop된 활동은 exit-done이어도 stack.activities에 남습니다(aggregate는 필터하지 않고 매핑·정렬만). forward 시 translateForwardtargetStateactivityId를 재사용해 push하면, 새 Pushed 이벤트(새 event id, 옛 activityId, 새 eventDate)가 발행되고 activitiesReducer가 같은 id의 기존(exit) 활동을 그 자리에서 makeActivityFromEventin-place 치환해 부활시킵니다(새 enter 전이, exitedBy 제거). 즉 activities 배열엔 그 id 활동이 하나만 존재하며 재진입 상태가 됩니다(중복 push 아님). 기존 plugin-history-sync의 forward 동작과 동일합니다.

(참고: 하니스 bridge는 exit-done 활동을 표시에서 필터하지만, getStack() 원본에는 forward 전까지 남아 있습니다.)

ENvironmentSet and others added 11 commits June 30, 2026 18:53
Batch removal of self-explanatory comments requested across PR #720
review threads (comment-only deletions grouped into a single commit).
Core invariant "why" comments (idle gating, side-effect-free pre-effect
hooks, sole-author) are kept.

Addressed review comments:
- historyState.ts: "주석 제거하세요." (#discussion_r3495721984)
- historyState.ts: "주석 제거하세요." (#discussion_r3495730697)
- HistorySyncController.ts: "주석 지우세요." (#discussion_r3497070797)
- HistorySyncController.ts: "주석 제거하세요." (#discussion_r3497029105)
- HistorySyncController.ts: "주석 제거하세요." (#discussion_r3497074684)
- HistorySyncController.ts: "주석 제거하세요." (#discussion_r3497205976)
- HistorySyncController.ts: "주석 제거하세요." (#discussion_r3497236213)
- HistorySyncController.ts: "주석 제거하세요." (#discussion_r3497281896)
- HistorySyncController.ts: "주석 제거하세요." (#discussion_r3497384291)
- HistorySyncController.ts: "주석 제거하세요." (#discussion_r3497383843)
- historySyncPlugin.tsx: "주석 지우세요." (#discussion_r3497453500)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zkdvyQTHayZMSLw5AUn9q
…ames

Align ControllerActions with Stackflow v2 action vocabulary:
stepPush -> pushStep, stepPop -> popStep. Core action names are
unchanged; the plugin adapter maps the new controller names onto
core's stepPush/stepPop.

Addressed review comments:
- HistorySyncController.ts: "Stackflow version 2에서는 pushStep으로 이름을 바꾸었습니다." (#discussion_r3496959058)
- HistorySyncController.ts: "Stackflow version 2에서는 popStep으로 이름을 바꾸었습니다." (#discussion_r3496959526)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zkdvyQTHayZMSLw5AUn9q
Clear the suppression token in dispose() so a stray in-flight token
cannot linger across teardown.

Addressed review comment:
- HistorySyncController.ts: "inFlight도 reset 해야 하지 않을까요?" (#discussion_r3497263605)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zkdvyQTHayZMSLw5AUn9q
Guard against double initialization: if a listener is already
registered, start() throws instead of silently leaking a listener.

Addressed review comment:
- HistorySyncController.ts: "`this.unlisten`이 이미 초기화되어 있는 경우 예외를 던지세요." (#discussion_r3497246411)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zkdvyQTHayZMSLw5AUn9q
Normalize the optional useHash option to a concrete boolean in the
constructor so internal state is simpler (option stays optional at
the boundary).

Addressed review comment:
- HistorySyncController.ts: "Option에서는 optional하더라도, 객체가 생성되는 시점에는 default 값을 넣어서라도 상태를 단순화하는게 좋지 않을까요?" (#discussion_r3497225528)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zkdvyQTHayZMSLw5AUn9q
The prior commit placed the inFlight reset in scheduleSync by mistake;
it belongs in dispose() (teardown). scheduleSync must not clear an
in-flight suppression token between sync passes.

Addressed review comment:
- HistorySyncController.ts: "inFlight도 reset 해야 하지 않을까요?" (#discussion_r3497263605)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zkdvyQTHayZMSLw5AUn9q
Parse the browser entry state once at the top of syncPass and throw when
it has no ordinal — an unstamped/foreign entry must never reach the sync
pass (sole-author invariant). Reuse the parsed state to drop the
duplicate parse and the dead !browserState branch.

Verified: e2e harness 87/87 green (x3 deterministic, the throw never
fires), so this surfaces a broken assumption loudly instead of
proceeding silently.

Addressed review comments:
- HistorySyncController.ts: "plugin-history-sync invariants 내지는 동작 가정(preconditions)가 깨진 건데 사용자가 인지할 수 있도록 예외 발생시키는게 맞지 않을까요?" (#discussion_r3497340109)
- HistorySyncController.ts: "`!browserState`면 예외 발생시켜야 하지 않을까요? 마찬가지로 invariants 위반이니." (#discussion_r3497358774)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zkdvyQTHayZMSLw5AUn9q
…d entries

stack.activities is already ordered bottom-to-top by core (aggregate
sorts by ascending, monotonic activity id), so committed entries read
that array order directly. Direction and distance still come from the
plugin-owned entry ordinal, not id comparison (ADR-0003).

Verified: e2e harness 87/87 green (x2, array order == prior zIndex order).

Addressed review comment:
- HistorySyncController.ts: "`stack.activities`는 이미 가장 아래 activity가 가장 밑으로 오도록, 쌓인 순서대로 정렬되어 있기 때문에 추가 정렬을 요하지 않습니다." (#discussion_r3497108663)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zkdvyQTHayZMSLw5AUn9q
…n-flight ops

Add a pendingSync flag so a sync requested while a self-induced history
op is in flight (or during a transition) is remembered and flushed once
the op settles, rather than relying implicitly on level-triggering plus
the release-side re-schedule. scheduleSync marks pendingSync and tries to
flush; flushSync gates on idle / in-flight and consumes the flag only
when it actually runs the pass.

Behavior is unchanged (the prior code did not drop syncs); this makes the
"remember the reservation while in flight" guarantee explicit.

Verified: e2e harness 87/87 green (x3 deterministic, concurrency passes).

Addressed review comment:
- HistorySyncController.ts: "`inFlight` 종료 전에 stack idle 상태에 먼저 도달하면 그 사이에 있었던 변화는 동기화 누락되지 않을까요? `inFlight` 일 때에는 flight 이후로 syncPass를 스케줄하는 로직이 scheduleSync에 있어야 하지 않나 싶습니다." (#discussion_r3497297726)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zkdvyQTHayZMSLw5AUn9q
An empty entered stack is unreachable (the root activity cannot be
popped, and overrideInitialEvents guarantees at least one activity), so
treat it as a broken invariant and throw instead of returning silently.

Verified: e2e harness 87/87 green (x2, the throw never fires).

Addressed review comment:
- HistorySyncController.ts: "Stack에 active activity가 하나도 없으면 invariants가 깨진 건데 조용히 return 할 게 아니라 예외를 발생시켜야 하지 않을까요?" (#discussion_r3497325073)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zkdvyQTHayZMSLw5AUn9q
The sync pass only mutates the browser while idle, where every entered
activity is enter-done, so committed entries already reflect only the
enter-done state. Narrow isEntered from (enter-active || enter-done) to
enter-done to make that explicit; behavior is unchanged.

Verified: e2e harness 87/87 green (x2).

Addressed review comment:
- HistorySyncController.ts: "`enter-active` 요소를 미리 commit하는게 맞을까요? `enter-done`만 커밋해야 하지 않을까요?" (#discussion_r3497111528)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011zkdvyQTHayZMSLw5AUn9q
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant