Skip to content

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

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

feat(plugin-history-sync): support preventDefault via history reconciliation (FEP-2001)#719
ENvironmentSet wants to merge 5 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. Unknown entries (previous sessions) are preserved optimistically as restoration targets; 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 recorded 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.

Expected conditions (prevented navigation, unknown entries, out-of-app cursor, paused stack) are handled as values; unexpected desync (go timeout, convergence cap, out-of-range op) raises HistorySyncDesyncError with diagnostics, one resync attempt, and an explicit give-up — nothing is silently swallowed.

Follow-up: per-tab entry journal for cross-reload restoration fidelity

Two residual limitations surfaced during review were resolved in the same PR:

  1. Protected restoration targets (Obs-1) — a blocked backward restoration across a reload boundary used to rewrite the previous session's entry it landed on (losing one back-step of granularity). Known entries now carry a provenance (session-write / journal / observation), and rewrite/truncation eligibility is restricted to entries this session itself wrote. Observed-only and journal-restored entries remain protected restoration targets even after being visited — fixed in both journal and no-journal fallback modes.
  2. Cross-reload multi-entry jumpshistory.go(±n) and long-press history jumps over entries recorded before a reload used to restore only the landing entry's snapshot, losing the intermediate stack chain. A new internal HistoryEntryJournal (sessionStorage, per tab, same flatted serialization as history state) records this app's entries; on boot it is validated against the current entry and seeds the model — stack boot restoration is unchanged (no UX/loader change). Backward jumps into journal-known territory replay the known ancestor chain as historical events (user-visible exits still go through formal, preventable pop() dispatches), and forward jumps replay known intermediates, so back/forward chains stay intact across reloads.

Journal failures (storage unavailable, SecurityError on access, quota, corrupted/foreign payload, version mismatch) are expected conditions: one-shot diagnostics, graceful fallback to the previous optimistic behavior, fully isolated from the reconcile path.

Commits (5 stages)

Commit Stage
2c1253c0 Test cleansing — removed a tautological spec and a Relay-coupled context spec (+ orphan Relay/GraphQL fixtures and devDependencies), keeping every observable-contract test.
29200573 Acceptance harness — deterministic browser-history harness (snapshot-stabilization settle, no fixed sleeps, isolated window shim) + 14 specs; 8 were 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 Follow-up acceptance specs — Obs-1 (both modes) and cross-reload jump fidelity as 4 it.failing specs + 5 green specs (prevent combinations, dual instance, storage fallback); harness gained a sessionStorage shim (fault/absence/access-throw) and reload simulation.
56a78723 Entry journal — provenance-based rewrite protection + journal-seeded chain replay; all 4 it.failing specs flipped to green.

Verification

  • @stackflow/plugin-history-sync: 8 suites / 78 tests green (repeated runs, no flakes) — including all 27 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 in adversarial loops; 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 (sessionStorage); history shared into a new tab falls back to optimistic restoration — the pre-journal behavior.

🤖 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: 56a7872

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: 70ac777c-f9b9-4a43-9d84-808fbcb3c0b2

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: 56a7872

@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 56a7872 Commit Preview URL Jun 11 2026, 03:57 AM

@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: 56a7872
Status: ✅  Deploy successful!
Preview URL: https://d017cf46.stackflow-demo.pages.dev
Branch Preview URL: https://feature-fep-2001.stackflow-demo.pages.dev

View logs

ENvironmentSet and others added 2 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>
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