Skip to content

fix(router): run popstate updates on app Runtime via runFork - #76

Merged
jonlaing merged 1 commit into
mainfrom
fix/popstate-run-on-app-runtime
Aug 3, 2026
Merged

fix(router): run popstate updates on app Runtime via runFork#76
jonlaing merged 1 commit into
mainfrom
fix/popstate-run-on-app-runtime

Conversation

@jonlaing

@jonlaing jonlaing commented Aug 3, 2026

Copy link
Copy Markdown
Owner

Summary

Fixes browser back/forward buttons not triggering re-renders. Directly diagnosed from a user report on SSG dev mode — same bug affects any client-navigation scenario that relies on popstate.

Root cause

The popstate handler ran the pathname signal update via Effect.runSync:

const handlePopState = () => {
  Effect.runSync(
    updateState(window.location.pathname + window.location.search),
  );
};

updateState calls Signal.setSubscriptionRef.set, which internally does semaphore.withPermits(1). Effect flags that as async-capable — runSync bails, throws. Browsers don't reliably surface exceptions from raw event listener callbacks to console.error, so the throw was silent: URL bar showed the new path, but the signal never updated, Outlet's subscribers never saw the change, and the page stayed on the previous route.

Interesting subtlety about why nobody noticed before: pushPath (called from Link's onClick) runs inside the app's Effect fiber — yield* updateState(...) executes fine because it's not going through runSync. Only the raw-event popstate path went through runSync. So forward navigation always worked; only browser back/forward was broken.

Diagnosis path

User's SSG dev-mode diagnostic pinned it:

[HomePage] render         /            ← initial
[NAV real signal changed to] /about   ← Link click, subscriber fires
[AboutPage] render          /about     ← Outlet reconciles
                                       ← Browser back button pressed here
[NAV signal after popstate] /          ← from a FRESH Navigation (misleading)
                                       ← NO signal-changed log from Outlet's Navigation

The [NAV real signal changed to] subscriber was attached to the SAME Navigation instance Outlet uses. Missing that log after back → the popstate handler ran (browser confirmed) but didn't propagate to the signal that Outlet subscribes to.

Fix

Capture the layer's Runtime at construction time and use Runtime.runFork:

if (isBrowser) {
  const runtime = yield* Effect.runtime<never>();
  const runFork = Runtime.runFork(runtime);

  const handlePopState = () => {
    runFork(
      updateState(window.location.pathname + window.location.search),
    );
  };
  // ...
}

Two benefits:

  1. runFork accepts async work — no throw for the semaphore permit.
  2. Runs on the SAME Runtime the rest of the app uses, so the update reliably reaches subscribers that live in that runtime.

Tests

Adds three regression tests in Navigation.test.ts:

  • pathname updates on popstate — the basic mechanism.
  • subscribers receive popstate changes — the exact shape Outlet uses (Stream.runForEach(nav.pathname.changes, ...)). This is the one that would have caught the bug had it existed before.
  • handler doesn't throw or fail silently — captures the shape of the original failure (silent throw from event handler).

Also adds a dispatchWindowEvent helper on the mock window so future tests can drive real browser event dispatch.

  • 792 tests pass across the workspace.
  • tsc --noEmit clean.

Changeset: @effex/router patch.

🤖 Generated with Claude Code

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 3, 2026

Copy link
Copy Markdown

Deploying effex with  Cloudflare Pages  Cloudflare Pages

Latest commit: 6651e7e
Status: ✅  Deploy successful!
Preview URL: https://c6b0fc09.effex.pages.dev
Branch Preview URL: https://fix-popstate-run-on-app-runt.effex.pages.dev

View logs

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 3, 2026

Copy link
Copy Markdown

Deploying effex-api with  Cloudflare Pages  Cloudflare Pages

Latest commit: 6651e7e
Status: ✅  Deploy successful!
Preview URL: https://93f64b51.effex-api.pages.dev
Branch Preview URL: https://fix-popstate-run-on-app-runt.effex-api.pages.dev

View logs

Browser back/forward wasn't triggering re-renders. The popstate handler
ran the signal update via Effect.runSync, but Signal.set uses
SubscriptionRef.set which involves semaphore.withPermits(1) — Effect
flags that as async-capable, so runSync bails. Since browsers don't
reliably surface exceptions from raw event listener callbacks to
console.error, the throw was silent: the URL bar showed the new path
but the pathname signal never updated, Outlet's subscribers never
received the change, and the page stayed on the previous route.

The reporter's SSG dev-mode diagnostic pinned it: after browser back,
their raw popstate listener fired with `pathname: "/"`, but a
subscriber to `nav.pathname.changes` on the SAME Navigation instance
Outlet uses received no notification.

Capture the Runtime at Layer construction time and use Runtime.runFork
to schedule the update. runFork accepts async work and runs it on the
same Runtime the rest of the app uses, so the signal update goes
through and subscribers pick it up.

Adds a browser-window mock helper (dispatchWindowEvent) plus three
regression tests: pathname updates on popstate, subscribers receive
popstate changes (Outlet's exact shape), and the handler doesn't
throw or fail silently.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@jonlaing
jonlaing force-pushed the fix/popstate-run-on-app-runtime branch from decb125 to 6651e7e Compare August 3, 2026 17:47
@jonlaing
jonlaing merged commit 2692f35 into main Aug 3, 2026
4 checks passed
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