feat(plugin-history-sync): support preventDefault via history reconciliation (FEP-2001)#720
feat(plugin-history-sync): support preventDefault via history reconciliation (FEP-2001)#720ENvironmentSet wants to merge 24 commits into
Conversation
…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
Deploying stackflow-demo with
|
| Latest commit: |
eb0bfdd
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://bf14f0fd.stackflow-demo.pages.dev |
| Branch Preview URL: | https://feature-fep-2001-reconciler.stackflow-demo.pages.dev |
🦋 Changeset detectedLatest commit: eb0bfdd The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
stackflow-docs | eb0bfdd | Commit Preview URL | Jul 03 2026, 09:44 AM |
commit: |
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
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
… to v2 names" This reverts commit b074101. stepPush/stepPop follow core's Actions interface, so keep those names in ControllerActions instead of renaming. Addressed review comments: - HistorySyncController.ts: "`stepPush` 명명은 core의 Actions 인터페이스를 따라간 거였군요? b074101b는 리버트합시다." (#discussion_r3503972092) - HistorySyncController.ts: "`stepPop` 명명은 core의 Actions 인터페이스를 따라간 거였군요? b074101b는 리버트합시다." (#discussion_r3503973376) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011zkdvyQTHayZMSLw5AUn9q
This reverts commit fe7b626. The useHash default is already handled in the historyState module (pushState/replaceState treat an undefined useHash as non-hash), so the constructor-level default is redundant. Addressed review comment: - HistorySyncController.ts: "useHash default value를 처리하는 지점이 Controller가 아니라 historyState 모듈이군요. fe7b626 리버트합시다." (#discussion_r3504001861) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011zkdvyQTHayZMSLw5AUn9q
Every entry this plugin writes carries an ordinal, and parseState returns null for entries not tagged by this plugin (isSerializedState), so a parsed non-null state always has a numeric ordinal. Make ordinal required and simplify the ordinal-presence checks accordingly. Verified: e2e harness 87/87 green (x2). Addressed review comment: - historyState.ts: "parseState가 읽을 때, 대상이 외부 엔트리면 isSerializedState에서 걸러지지 않나요? 내려와도 null이 내려오지, ordinal이 없는 state가 내려오지는 않을 것 같습니다. ... 우리가 write 할 때 ordinal 안 넣는 경우 없으니 required로 둡시다." (#discussion_r3498341212) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011zkdvyQTHayZMSLw5AUn9q
popstate only fires for same-document history traversal, so a navigation that leaves the app never reaches this listener; every popstate we receive lands on an entry this plugin stamped. An unrecognized entry is therefore an invariant violation — throw instead of returning silently. Verified: e2e harness 87/87 green (x2, the throw never fires). Addressed review comment: - HistorySyncController.ts: "PopStateEvent는 target history entry의 document에서만 발행됩니다. 따라서 Stackflow application을 이탈하는 네비게이션에 대해서 발행되는 popstate event는 우리 플러그인에게 전달되지 않으며 ... plugin-history-sync가 생성하지 않은/인식할 수 없는 엔트리가 존재하면 플러그인 불변식 위반이며, 오류 발생이 적절합니다." (#discussion_r3507815609) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011zkdvyQTHayZMSLw5AUn9q
…tDate Treat entered activities as a set and re-establish stack order explicitly by sorting on enteredBy.eventDate (later-entered on top), matching core's ordering, rather than relying on the incoming array order. Direction and distance still come from the entry ordinal, not id comparison. Verified: e2e harness 87/87 green (x2). Addressed review comment: - HistorySyncController.ts: "activities 배열이 지금은 정렬되어 제공되기는 하지만, 코드를 보아하니 Set에 더 가깝게 취급하는게 맞는 것 같습니다. 언제 정렬 메커니즘이 깨질지 모르니 정렬 코드 넣도록 하겠습니다. core처럼 `Activity.enteredBy.eventDate`로 비교해서 나중에 들어온 액티비티가 뒤로 가도록 정렬합시다." (#discussion_r3503999331) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011zkdvyQTHayZMSLw5AUn9q
Reword the empty entered-stack error to read as a requirement. Addressed review comment: - HistorySyncController.ts: "오류 메시지가 난해합니다. "At least one active activity was expected but empty stack is found"라고 하죠?" (#discussion_r3504173078) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011zkdvyQTHayZMSLw5AUn9q
…I sweeps - Add a changeset for the plugin-history-sync preventDefault reconciler. - Exclude the @stackflow/e2e-history-sync-blocker real-browser harness from the monorepo `ultra -r typecheck` / `ultra -r test` sweeps: it aliases product `src` (so a standalone tsc pulls in cross-package type errors) and its tests require Chromium (run separately). Rename its `test` to `test:e2e` and drop the `typecheck` script; test:t1 / test:t2i continue to work. Fixes the failing Typings / Test / Changesets checks on PR #720. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011zkdvyQTHayZMSLw5AUn9q
…mentation Reconcile the design docs with the as-built reconciler and the review decisions: - ordinal is a required history-state field (external entries are filtered out as unrecognized), so there is no "our entry without an ordinal". - committed-entry stack order is established by enteredBy.eventDate (like core), not by relying on incidental array order; direction/distance stay ordinal-based. - invariant violations fail loud (throw) instead of returning silently: unstamped browser entry, empty entered stack, and popstate for an unrecognized entry (popstate is same-document, so leaving the app never reaches us). - browser navigation is translated by browser/stack ordinal delta (movement), not by looking up the landed entry's activity/step id (all UI is Stack-based; identity-following browser buttons would surface Stack != History as confusing UX). [ADR-0003] - explicit pending-sync tracking flushes a reservation once an in-flight op settles. - the sole browser-mutation authority is the sync pass (plus a one-time bootstrap in start()); translate*/popstate only dispatch stack actions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011zkdvyQTHayZMSLw5AUn9q
무엇을 / 왜
@stackflow/plugin-history-sync가preventDefault와 안전하게 공존하지 못하던 네 가지 문제를 해소합니다. 이로써plugin-blocker등preventDefault소비 플러그인과 호환되고, 플러그인 자체가 안정화됩니다. (Linear: FEP-2001)해소한 네 문제:
preventDefault할 수 없음 — backward navigation을dispatchEvent("Popped")로 직접 발행해onBeforePoppre-effect 훅을 우회.pop()시 history desync —onBeforePop이history.back()을 비동기 큐에 등록한 뒤 다른 플러그인이preventDefault하면 스택은 불변인데 큐의history.back()은 실행됨(onBeforeStepPop·onBeforeReplace도 동일).pushFlag카운터 오염 — 출처 추적 카운터가 prevented 시 누수되어 이후 정상 push에서 동기화를 건너뛰는 연쇄 desync.onBeforePop훅 실행 순서 의존성 — pre-effect 훅의 비가역 부작용이 등록 순서에 의존.어떻게 (메커니즘)
네 문제는 한 뿌리를 공유합니다 — 플러그인이 브라우저 history를 여러 곳에서(pre-effect 훅, post-effect 훅, 낙관적 카운터) 제각기 만지고, 브라우저 뒤로가기를 액션 경로가 아닌
dispatchEvent로 우회한 것. 이를 reconciler 구조로 뒤집어 브라우저를 "커밋된 스택"의 엄격한 추종자로 만듭니다.HistorySyncController): 브라우저 history를 변경하는 권위를 하나의 동기화 과정으로 모읍니다. history는 커밋된 스택 변화에만 반응합니다(원리 A — 커밋된 effect에만 history를 만진다).pop/stepPop/push)으로 번역됩니다. 그래서 다른 플러그인의onBefore*가 실행되고preventDefault가 존중됩니다. 막히면 동기화가 브라우저를 복원하고, 커밋되면 동기화가 따라갑니다.state에 stamp한 플러그인 내부 ordinal로 계산합니다(core id는 동일성 매칭에만). id 순서성에 기대지 않습니다.delta = stackOrdinal − browserOrdinal로 정착(idle) 시점에만 1회 동기화. 버퍼링되는 비동기 커밋을 자동 흡수하고, 같은 차이엔 무동작.history.go만 단일 in-flight 토큰으로 1:1 소비하고, 이동 없는 동작엔 토큰을 set하지 않아 누수가 없습니다.pre-effect 훅에서 비가역 부작용(
history.back()큐잉)·누수 카운터(pushFlag)·우회 경로(dispatchEvent("Popped"))·억제 플래그(silentFlag)를 전부 적출했습니다. 결과적으로 네 문제가 모두 영구 desync에서 정지점마다 자가치유되는 일시 글리치로 범주가 바뀝니다(eventual consistency).문제별 해소
dispatchEvent우회 폐기 → 액션 파이프라인 번역으로onBeforePop·preventDefault존중history.back()큐잉 전량 제거 → prevented 시 커밋 0 → history 무변pushFlag폐기 → 출처는(스택, 엔트리)ordinal 차이의 귀결, 누수 불가범위
plugin-history-sync에 갇힘:extensions/plugin-history-sync/src/의 4파일(HistorySyncController.ts신규,historyState.ts,historySyncPlugin.tsx재작성,historySyncPlugin.spec.ts).@stackflow/core의 이벤트/훅 계약 불변. 공개 이벤트/effect에 새 필드 없음(ordinal은 historystate내부 데이터).검증 —
preventDefaultreconciler 하니스 (e2e/)구현보다 먼저 만든 검증 하니스를 함께 추가합니다 (
e2e/,@stackflow/e2e-history-sync-blocker):plugin-history-sync와plugin-blocker를 모두 적용한 상태로 검증.preventDefault소비자 호환 계약 + 동시성/재진입 + 양 플러그인 기존 스위트 케이스 1:1.getStack)·NAVIGABILITY·blocker 통보 로그만 단언(내부 좌표 비단언).설계 근거는
plans/fep-2001/(solution-plan · glossary · ADR 0001~0008)에 함께 담았습니다.Draft 체크리스트 (ready 전)
@stackflow/plugin-history-syncminor/patch)Closes FEP-2001
🤖 Generated with Claude Code
https://claude.ai/code/session_011zkdvyQTHayZMSLw5AUn9q