Skip to content

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

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

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

Conversation

@ENvironmentSet

@ENvironmentSet ENvironmentSet commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

Resolves FEP-2001

Problem

plugin-history-sync could not compose with preventDefault:

  1. Browser back bypassed the action pipeline — the popstate handler dispatched Popped/StepPopped directly via dispatchEvent, so no plugin's onBeforePop (e.g. plugin-blocker) ever ran for browser-initiated navigation.
  2. Prevented navigations still mutated historyhistory.back() ticks were queued in onBeforePop/onBeforeStepPop/onBeforeReplace before knowing whether another plugin would prevent the event, leaving the URL and the stack permanently out of sync.
  3. pushFlag leaked on prevented forward navigations — the counter was incremented before push(), and never decremented when the push was prevented, silently swallowing history sync for subsequent pushes.
  4. Hook-order dependence — pre-effect side effects could not be rolled back once a later plugin called preventDefault().

Two deeper constraints shaped the solution: the core runs post-effect hooks synchronously (nested dispatches re-enter mid-chain, so hook-time queueing can never guarantee operation order), and the Popped reducer truncates steps on direct exit-done transitions (so effect-payload-based entry counting is unreliable).

Solution: reconciliation

The imperative delta-and-flag engine is replaced with a reconciliation engine that treats browser history as a render target:

  • Desired entries are computed from the latest stack at reconcile time (entered activities × live steps), so intermediate states created by reentrant dispatches collapse naturally.
  • Actual model (BrowserHistoryEntryModel) tracks the real browser history with absolute entryIndex coordinates persisted in history state. Every known entry carries a provenance (session-write / journal / observation); entries below the app's anchor are never touched.
  • Serial reconciler (HistoryReconciler) converges actual onto desired through a mutex queue, one operation per iteration, recomputing both sides each time. Self-induced popstates are identified by expectation matching (replacing silentFlag); stale forward branches written this session are truncated via push semantics.
  • popstate → formal actions: browser back/forward/go now dispatch actions.pop()/stepPop()/push()/stepPush(), so every plugin's onBefore* hooks and preventDefault work for browser navigation. A prevented navigation leaves the stack unchanged and the reconciler restores the browser to the stack's position automatically.
  • Hooks perform no history operations; onBeforePush/onBeforeReplace only fill activityContext.path. pushFlag and silentFlag are gone.

Cross-reload restoration fidelity (HistoryEntryJournal)

A per-tab journal (sessionStorage, same flatted serialization as history state, internal module) records the entries this app wrote, so knowledge survives reloads:

  • Rewrite/truncation eligibility is restricted to session-write entries. Journal-restored and observed-only entries remain protected restoration targets even after being visited — a blocked back across a reload boundary no longer rewrites a previous session's step entry (back-granularity preserved). The protection also holds in no-journal fallback mode (storage unavailable/invalid), where observed-only entries are simply never rewritten.
  • Multi-entry jumps (go(±n), history long-press) across reload boundaries reconstruct the intermediate chain: backward landings replay the known ancestor chain historically (each user-visible pop still goes through the formal, preventable pipeline) and materialize forward-region slots so core slot ordering matches navigation order; forward jumps replay known intermediate entries.
  • Journal failures (quota, SecurityError access, corruption, version/identity mismatch) are expected conditions: one-shot diagnostic, then graceful fallback to the optimistic partial model. Journal calls are fully isolated from the reconcile path. Memory history (SSR/tests) uses a no-op adapter.

Scope guarantees: @stackflow/core unchanged; public plugin API unchanged; new modules are internal (not exported); HistoryQueueContext contract preserved; SSR / defaultHistory staged setup and post-reload boot UX preserved.

Commits

Commit Content
2c1253c0 Test cleansing — removed a tautological spec and a Relay-coupled context spec (restored later in ad078638 at maintainer request).
29200573 Acceptance harness — deterministic browser-history harness (snapshot-stabilization settle, no fixed sleeps, isolated window shim) + 14 specs; 8 it.failing encoding the target behavior.
380ed94c Reconciliation engine — the rewrite; all 8 it.failing specs flipped to green, plus 3 regression tests reproducing reviewer probes (stale forward branch truncation, in-place replace root preservation, replace-shrink truncation).
6750b448 Cross-reload acceptance specs — sessionStorage/reload harness extensions + 4 it.failing (Obs-1 both modes, cross-reload chain fidelity) + prevent/dual-instance/storage-fault specs.
56a78723 Entry journal — provenance-based rewrite protection + journal seeding + historical chain replay; the 4 it.failing specs flipped to green.
ad078638 Test restoration — restored the two stage-1-removed tests (fallbackActivity single-call, relay loadRef context) with Relay fixtures/deps; both pass against the new engine.

Verification

  • @stackflow/plugin-history-sync: 8 suites / 80 tests green (repeated runs, no flakes) — including all pre-existing engine specs unchanged
  • @stackflow/plugin-blocker: 37 tests green (proceed-replay and notification-order contracts intact)
  • SSR/hydration specs green; tsc --noEmit, biome, build green
  • Each stage reviewed by two independent reviewers (3 rounds for the engine, 2 for the follow-up specs, 1 for the journal); every blocking finding was reproduced with a probe before fixing and is pinned by a regression test

Known limitations (documented, non-blocking)

  • Root-entry in-place rewrite cannot truncate (no predecessor to push from) — behavior matches the legacy engine.
  • Journal knowledge is per-tab and only covers entries written by this app; jumps into ranges never recorded (e.g. storage cleared mid-session) degrade gracefully to landing-snapshot restoration.

🤖 Generated with Claude Code

ENvironmentSet and others added 3 commits June 10, 2026 19:35
…sts (FEP-2001 stage 1)

Remove the relay loadRef activity.context test because Relay integration is outside the plugin-history-sync public contract.

Remove the fallbackActivity single-call plugin-instance test because it manually invokes overrideInitialEvents once and asserts the same call count.

Delete the orphan Relay GraphQL fixtures and remove Relay/GraphQL-only dev dependencies, config, PnP, cache, and lockfile entries.
…and FEP-2001 target specs (stage 2)

The 8 it.failing specs are the acceptance criteria for the stage 3 reconciliation implementation. They should be converted to normal it specs once reconciliation is implemented and the target behavior is satisfied.
…ation to support preventDefault (FEP-2001)

Replace the imperative delta-and-flag paradigm (queueing history.back() in
onBefore* hooks, counting pushFlag, muting popstates with silentFlag) with a
desired/actual reconciliation engine: the desired entry list is computed from
the current stack (entered activities x live steps), the actual browser
history is tracked by a partial per-session model (entryIndex coordinates,
optimistic unknown entries preserved as restoration targets), and a serial
reconciler converges the browser onto the stack with go/pushState/replaceState
— one operation per iteration, recomputing both sides each time.

popstate-driven navigations (back/forward/go) are now translated into the
formal action path (actions.pop/stepPop/push/stepPush), so every plugin's
onBefore* hooks — including preventDefault — participate in browser-initiated
navigation. When a navigation is prevented the stack stays unchanged and the
unconditional post-popstate reconcile pass restores the browser to it
automatically. Hooks no longer perform history operations, making re-entrant
dispatches safe (intermediate states collapse into the next reconcile pass),
and pushFlag/silentFlag are removed entirely (self-induced popstates are
identified by expected entryIndex instead).

Unfails the 8 stage-2 acceptance specs (it.failing -> it) covering
blocker-interop back/forward/step navigation, proceed replay, rapid back
convergence, re-entrant navigation inside onBlocked, and go(n) jumps; adds 3
regression tests from review probes (stale forward-branch truncation after
back+push and after replace-shrink, in-place replace preserving ancestors).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Jun 10, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 1fb2a08

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

@coderabbitai

coderabbitai Bot commented Jun 10, 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: df104ef0-b1ea-4008-a4f7-81337b33117a

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

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 and usage tips.

@pkg-pr-new

pkg-pr-new Bot commented Jun 10, 2026

Copy link
Copy Markdown
  • @stackflow/demo

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

commit: 1fb2a08

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jun 10, 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 1fb2a08 Commit Preview URL Jun 11 2026, 12:10 PM

@cloudflare-workers-and-pages

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

Copy link
Copy Markdown

Deploying stackflow-demo with  Cloudflare Pages  Cloudflare Pages

Latest commit: 1fb2a08
Status: ✅  Deploy successful!
Preview URL: https://043ee4fd.stackflow-demo.pages.dev
Branch Preview URL: https://feature-fep-2001.stackflow-demo.pages.dev

View logs

ENvironmentSet and others added 3 commits June 11, 2026 00:10
…journal lifecycle specs (FEP-2001 follow-up)

① Obs-1(관찰-only 엔트리 보호) 양 모드 스펙과 cross-reload 멀티 점프 체인 충실도 스펙은 it.failing 4건으로 후속 구현의 수용 기준.

② harness에 sessionStorage shim(fault/부재/접근 throw)과 reloadHarness 추가.
… restoration fidelity (FEP-2001 follow-up)

- Restrict rewrite/truncation eligibility to entries this session itself
  wrote, via an explicit provenance on known entries: journal-restored and
  observed-only entries stay protected restoration targets even after being
  visited, resolving Obs-1 (blocked back across a reload boundary no longer
  rewrites a previous session's step entry) in both journal and no-journal
  fallback modes.
- Introduce HistoryEntryJournal (sessionStorage, flatted, internal): boot
  seeds the model with validated previous-session entry knowledge (stack
  restoration unchanged), and backward/forward multi-entry jumps replay the
  journaled chain historically (original event ids/dates) — including slot
  materialization for the forward region so the core's slot-order-derived
  active activity stays aligned with navigation order — restoring stack
  fidelity for cross-reload go(±n).
- Journal failures (quota, privacy modes, corrupt or mismatching persisted
  data) are expected: they degrade to the optimistic per-entry behavior with
  a one-time diagnostic and never block reconciliation.
- Unflag the four cross-reload acceptance specs (Obs-1 journal/fallback,
  backward and forward multi-entry jump fidelity) — now passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…elay loadRef context tests

Restore the two tests removed in 2c1253c at maintainer request: fallbackActivity single-call per plugin instance and relay loadRef activity context loading. Also restore the relay fixture/dependency support needed by the context test. Verified that both restored tests pass against the reconciliation engine from 380ed94 and 56a7872.
Summarize the FEP-2001 work for continuation by other agents, in four
documents under .agents/worklogs/fep-2001/: problem recognition and
definition (01), direction setting with rejected approaches and decision
principles (02), solution design for the reconciliation engine and the
entry journal (03), and implementation context with the full decision
logs D1-D10/FD1-FD10, core behavior facts, commit map, review history,
and known limitations (04).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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