Skip to content

fix(router): support experimental gesture push#1909

Open
NathanDrake2406 wants to merge 11 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/fix-gesture-transitions
Open

fix(router): support experimental gesture push#1909
NathanDrake2406 wants to merge 11 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/fix-gesture-transitions

Conversation

@NathanDrake2406

Copy link
Copy Markdown
Contributor

Overview

Field Detail
Goal Match the upstream Next.js gesture-transition deploy test when apps enable experimental.gestureTransition.
Core change Resolve experimental.gestureTransition, define process.env.__NEXT_GESTURE_TRANSITION, and expose useRouter().experimental_gesturePush() only when enabled.
Runtime boundary Gesture navigation reuses Vinext's existing RSC fetch, classification, merge, and history commit path, but publishes the approved visible commit synchronously so React's held async transition can observe the target route.
Primary review files packages/vinext/src/shims/navigation.ts, packages/vinext/src/server/app-browser-navigation-controller.ts, packages/vinext/src/config/next-config.ts, tests/e2e/app-router/nextjs-compat/gesture-transitions.spec.ts
Expected impact Next.js apps using the experimental gesture API no longer fail with experimental_gesturePush is not a function, and the target page becomes visible during the held gesture transition.

Why

The App Router public router surface has to be gated by the same config-derived compile-time feature flag as Next.js. The upstream fixture calls experimental_gesturePush() inside an async transition that does not complete until a later button click, so an ordinary transition-lane navigation is not observable at the intermediate assertion point.

Area Principle / invariant What this PR changes
Config Experimental API availability should follow next.config.js. Adds experimental.gestureTransition to config resolution and emits process.env.__NEXT_GESTURE_TRANSITION.
Router API useRouter() should expose the same optional method shape under the feature flag. Adds optional experimental_gesturePush to the App Router instance and window.next.router type.
Navigation lifecycle A gesture's intermediate state must be visible before the canonical router.push() resolves the held transition. Adds a synchronous visible-commit mode after the existing RSC payload is decoded and approved.

What changed

Scenario Before After
experimental.gestureTransition enabled router.experimental_gesturePush was undefined. The method is attached under process.env.__NEXT_GESTURE_TRANSITION.
Gesture starts inside a held async transition The route fetch could run, but visible state stayed on the source page until the canonical push. The approved navigation payload commits visibly during the gesture.
External gesture href Could have followed the normal router path if implemented through push. Same-origin hrefs navigate; external absolute/protocol-relative hrefs no-op, matching Next's gesture path.
Maintainer review path
  1. packages/vinext/src/config/next-config.ts and packages/vinext/src/index.ts: config resolution and define plumbing for __NEXT_GESTURE_TRANSITION.
  2. packages/vinext/src/shims/navigation.ts: gated public method, URL safety, same-origin normalization, and synchronous visible-commit invocation.
  3. packages/vinext/src/server/app-browser-navigation-controller.ts and packages/vinext/src/server/app-browser-entry.ts: shared commit-mode plumbing that keeps normal navigations on the transition lane and gives gesture an immediate approved visible commit.
  4. tests/e2e/app-router/nextjs-compat/gesture-transitions.spec.ts and fixture files: faithful port of the upstream held-transition behavior under the shared app-basic route prefix.
Validation
  • vp check
  • vp test run tests/next-config.test.ts -t gestureTransition
  • vp run vinext#build
  • PLAYWRIGHT_PROJECT=app-router npx playwright test tests/e2e/app-router/nextjs-compat/gesture-transitions.spec.ts --project=app-router
  • vp env exec --node 24 ./scripts/run-nextjs-deploy-suite.sh /Users/nathan/Projects/vinext/.refs/nextjs-v16.2.6 --retries 0 -c 1 --debug test/e2e/app-dir/gesture-transitions/gesture-transitions.test.ts
  • Pre-commit hook also ran staged checks, full check, staged unit/integration tests, and knip.
Risk / compatibility
  • Public API: adds an optional experimental method only when the matching experimental config flag is true.
  • Router lifecycle: normal push, replace, Link, form, refresh, and traversal paths keep the default transition visible-commit mode.
  • Compatibility: this does not implement Next's full internal Gesture Transition or Segment Cache/PPR machinery. It preserves the deploy-test observable contract by committing a normal approved Vinext RSC payload synchronously for gesture navigation.
  • Existing app-basic tests now run with experimental.gestureTransition: true; the only exposed difference is the optional router method.
Non-goals
  • Implementing React Gesture Transition internals.
  • Implementing Next.js Segment Cache FreshnessPolicy.Gesture or partial PPR dynamic-hole behavior.
  • Changing config behavior for unrelated experimental flags.

References

Link Why it matters
Next.js gesture transition deploy test Upstream behavior ported by this PR.
Next.js gesture fixture layout Shows the held async transition that requires the intermediate state to be visible.
Next.js App Router instance Source for the gated experimental_gesturePush public method.
Next.js define env plumbing Source for process.env.__NEXT_GESTURE_TRANSITION.
Next.js action queue optimistic state Explains why gesture state needs to be visible before the held action completes.

Vinext did not expose useRouter().experimental_gesturePush when Next.js apps enabled experimental.gestureTransition. Upstream gesture-transition fixtures call the method inside a held async transition, so the target route never became visible before the canonical router.push completed.

The fix resolves the config flag into the same __NEXT_GESTURE_TRANSITION define used by Next.js, exposes the optional router method only under that flag, and lets gesture navigation use the existing RSC navigation pipeline with an immediate approved visible commit. The ported e2e fixture preserves the upstream held-transition flow and target/static/dynamic assertions.
@pkg-pr-new

pkg-pr-new Bot commented Jun 11, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@1909
npm i https://pkg.pr.new/vinext@1909

commit: 8a041be

@NathanDrake2406 NathanDrake2406 marked this pull request as draft June 11, 2026 06:38
@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review June 11, 2026 17:10
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: experimental gesture push

Clean, well-scoped change. The config plumbing, define injection, gated public method, and synchronous-commit lane are all consistent with the existing patterns, and the test/fixture changes track the new visibleCommitMode parameter correctly. I traced the key paths:

  • experimental_gesturePush is attached to appRouterInstance (_appRouter), which is what AppRouterContext.Provider is fed (app-browser-entry.ts:1098, app-ssr-entry.ts:391) and what installWindowNext registers, so it flows to both useRouter() and window.next.router.
  • The gesture path uses programmaticTransition = false, so pendingRouterState is null and dispatchApprovedVisibleCommit reaches the new synchronous branch (controller line 529) rather than the early pendingRouterState return. Internally consistent.
  • flushSync from react-dom is already imported in app-browser-entry.ts, so the new controller import is safe in the browser bundle.
  • JSON.stringify(nextConfig.gestureTransition) emits a raw boolean literal, so if (process.env.__NEXT_GESTURE_TRANSITION) tree-shakes away when disabled. Good.
  • The new NextConfig.experimental type keeps a [key: string]: unknown index signature, so existing configs that set other experimental.* keys still type-check (they were previously accepted via the top-level index signature).

A few non-blocking observations below.

appHref = localPath;
}

void navigateClientSide(appHref, "push", options?.scroll !== false, false, "synchronous");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Unlike push/replace, the gesture path does not call trackScheduledAppRouterNavigation() / releaseScheduledAppRouterNavigationAfterCurrentTask(). That bookkeeping is what lets a router.refresh() issued in the same outer transition skip a redundant re-fetch (see hasScheduledAppRouterNavigation() at line 1973). In the ported fixture this is masked because the canonical router.push(href) runs after the await and does track, but a userland gesture followed by an immediate refresh() would diverge from push. Worth a short comment explaining why gesture intentionally skips the scheduled-navigation guard, or adding the tracking for parity.

return;
}

if (visibleCommitMode === "synchronous") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The synchronous branch is only reachable when pendingRouterState is null (the early return at line 521 wins otherwise). Today the only synchronous caller is gesture push with programmaticTransition = false, so that invariant holds, but it is implicit. If a future caller ever combined visibleCommitMode: "synchronous" with a programmatic transition, the synchronous commit would be silently dropped in favor of the deferred resolve. A one-line comment noting that synchronous mode assumes a null pendingRouterState would make the coupling explicit.

},
};

if (process.env.__NEXT_GESTURE_TRANSITION) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No unit test asserts that experimental_gesturePush is attached when the define is set (the new next-config.test.ts cases only cover config resolution; the e2e spec covers the runtime). Since process.env.__NEXT_GESTURE_TRANSITION is undefined in vitest, appRouterInstance.experimental_gesturePush is always undefined there. Consider a focused unit test that stubs the env define and re-imports the shim to assert the method is (and is not) attached, mirroring how the gating works. Not blocking given the e2e coverage.

@ask-bonk

ask-bonk Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully. Here's a summary of my findings:

Review Summary

The PR is a clean, well-scoped implementation that correctly adds the experimental gesture push API gated by experimental.gestureTransition. I verified the key data flows and found no blocking issues.

Verified correct:

  • experimental_gesturePush propagates to both useRouter() and window.next.router (attached to appRouterInstance / _appRouter, which is what the context provider and installWindowNext use).
  • The synchronous commit branch is reachable because gesture uses programmaticTransition = false (so pendingRouterState is null).
  • flushSync import is safe in the browser bundle (already used in app-browser-entry.ts).
  • The define injects a raw boolean, so the guard tree-shakes away when disabled.
  • The new NextConfig.experimental type keeps an index signature, so existing configs don't regress.
  • Test/fixture changes correctly track the new 9th visibleCommitMode parameter.

Three non-blocking observations posted as inline comments:

  1. Gesture path skips trackScheduledAppRouterNavigation() bookkeeping that push/replace use — could subtly diverge for a gesture-then-refresh() sequence. Suggested a clarifying comment or adding the tracking.
  2. The synchronous controller branch has an implicit invariant (requires null pendingRouterState) worth documenting.
  3. No unit test asserts the method is attached/omitted based on the define (only e2e covers runtime). Suggested a focused stub-and-reimport test.

No correctness or security issues were found.

github run

- track scheduled app router navigation in experimental_gesturePush for
  parity with push/replace, so a refresh() issued in the same task skips
  its redundant re-fetch
- document the synchronous visible-commit mode's null pendingRouterState
  invariant in the browser navigation controller
- add a unit test asserting experimental_gesturePush is only attached
  when the __NEXT_GESTURE_TRANSITION define is set
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: experimental gesture push

Verified the change end-to-end and it is correct, well-scoped, and faithful to the upstream behavior. I confirmed the items the previous /bigbonk review flagged are now resolved in this diff:

  • The scheduled-navigation tracking the earlier review asked for is present (trackScheduledAppRouterNavigation() / releaseScheduledAppRouterNavigationAfterCurrentTask) at shims/navigation.ts:2063-2065, so a gesture-then-refresh() in the same task now matches push/replace.
  • The implicit synchronous invariant is now documented at app-browser-navigation-controller.ts:529-533.
  • A focused unit test asserting attach/omit based on the define was added (shims.test.ts:329).

Things I independently verified as correct:

  • experimental_gesturePush is mutated onto the _appRouter/appRouterInstance singleton, which is the value fed to AppRouterContext.Provider (app-browser-entry.ts:1098, app-ssr-entry.ts:390) and to installWindowNext({ router: appRouterInstance }) (app-browser-entry.ts:2264). So it correctly surfaces on both useRouter() and window.next.router. Because the top-level if runs at module-load (before any render), useRouter's useMemo([router, bfcacheId]) never goes stale.
  • The synchronous branch is reachable: gesture navigates with programmaticTransition = false, so pendingRouterState is null and the early return at controller:521 does not win. dispatchApprovedVisibleCommit runs after the RSC payload await, i.e. outside any React render phase, so flushSync is safe.
  • The define injects a raw boolean (JSON.stringify(nextConfig.gestureTransition)), so if (process.env.__NEXT_GESTURE_TRANSITION) tree-shakes out when disabled.
  • The new NextConfig.experimental object type keeps its inner [key: string]: unknown, so configs setting other experimental.* keys still type-check.
  • The e2e spec and fixture are a faithful port of test/e2e/app-dir/gesture-transitions (v16.2.6), with a reasonable experimental_gesturePush === undefined throw added.
  • URL-safety / isServer ordering in the gesture method matches push/replace, and the throwing calls (assertSafeNavigationUrl, normalization) run before trackScheduledAppRouterNavigation(), so no scheduled-navigation leak on a synchronous throw.

Two minor, non-blocking notes inline. No correctness or security issues found.

if (isServer) return;

let appHref = href;
if (isAbsoluteOrProtocolRelativeUrl(href)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor: this same-origin normalization is redundant with navigateClientSide, which already runs the identical check (isExternalUrl is literally isAbsoluteOrProtocolRelativeUrl, navigation.ts:1562-1564) and normalizes absolute same-origin URLs at navigation.ts:1769-1788.

The one behavioral reason to keep it here is the external-URL case: doing it inline lets the gesture path no-op on external hrefs, whereas falling through to navigateClientSide would trigger a hard window.location.assign. That matches the PR's stated intent, so this is intentional — just worth a one-line comment noting it exists to force the external no-op rather than to normalize (which navigateClientSide would do anyway).

// early return above never wins. A future caller combining `synchronous`
// with a programmatic transition would have its synchronous commit
// silently dropped in favor of the deferred resolve above.
if (visibleCommitMode === "synchronous") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Worth noting there are now two synchronous-commit paths: the existing dispatchSynchronousVisibleCommit (plain setter(...), used by HMR and history traversal at lines 483/621/801) and this new flushSync-wrapped branch. The distinction is correct — the existing callers already run inside a synchronous event-handler/effect context where React batches and flushes, whereas gesture's commit fires after an await inside a held async transition and must be forced out with flushSync. Not a change request; just flagging the two paths so a future reader doesn't mistake them for duplicates and consolidate them incorrectly.

@ask-bonk

ask-bonk Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully. Here's a summary:

Review Summary

This PR adds the experimental experimental_gesturePush App Router API gated behind experimental.gestureTransition. After tracing the full change, I found it correct, well-scoped, and faithful to upstream Next.js — no blocking issues.

Confirmed the prior review's concerns are now resolved in this diff:

  • Scheduled-navigation tracking added to the gesture path (parity with push/replace for a gesture-then-refresh() sequence)
  • The implicit synchronous-mode invariant is documented in the controller
  • A focused unit test asserts the method is attached/omitted based on the define

Independently verified:

  • experimental_gesturePush flows to both useRouter() and window.next.router via the shared appRouterInstance singleton
  • The synchronous branch is reachable (gesture uses programmaticTransition = false) and flushSync runs after the RSC await, outside any React render phase, so it's safe
  • The define injects a raw boolean, so the guard tree-shakes away when disabled
  • The new NextConfig.experimental type preserves the inner index signature (no regression for other experimental.* keys)
  • The e2e spec/fixture faithfully port test/e2e/app-dir/gesture-transitions (v16.2.6)
  • URL-safety and throw ordering match push/replace, so no scheduled-navigation leak on a synchronous throw

Two minor, non-blocking inline notes:

  1. The gesture's same-origin URL normalization is redundant with navigateClientSide (it exists to force the external-href no-op) — worth a one-line comment
  2. There are now two synchronous-commit paths (dispatchSynchronousVisibleCommit vs the new flushSync branch) — both correct, just flagged so they aren't mistakenly consolidated later

No correctness or security issues found.

github run

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: experimental gesture push

Traced the full change. It is correct, well-scoped, and a faithful port of test/e2e/app-dir/gesture-transitions (v16.2.6). I independently re-verified the key invariants the two prior /bigbonk reviews flagged, and they all hold:

  • Gating / tree-shaking: defines["process.env.__NEXT_GESTURE_TRANSITION"] = JSON.stringify(nextConfig.gestureTransition) emits a raw boolean literal (true/false), so the module-top if (process.env.__NEXT_GESTURE_TRANSITION) guard in navigation.ts tree-shakes out when disabled. Note this differs from the sibling defines that stringify ? "true" : "false" (which would be truthy as "false"); the boolean form here is the correct one.
  • Reachability of the synchronous branch: gesture push calls navigateClientSide(..., programmaticTransition = false, "synchronous"). In app-browser-entry.ts:1673 shouldUsePendingRouterState = programmaticTransition, so pendingRouterState stays null and the early return at controller:521 does not win — the flushSync branch at controller:534 is reached. The new comment documenting that implicit invariant is accurate.
  • Surface area: experimental_gesturePush is mutated onto the shared _appRouter/appRouterInstance singleton, which is fed to AppRouterContext.Provider (app-browser-entry.ts:1098, app-ssr-entry.ts:391) and to installWindowNext({ router: appRouterInstance }) (passed by reference at window-next.ts:189), so it surfaces on both useRouter() and window.next.router.
  • Config resolution: experimental?.gestureTransition === true coerces the unknown value safely; default is false; the NextConfig.experimental object type retains its inner [key: string]: unknown so other experimental.* keys still type-check.
  • No collateral effect from enabling the flag on the shared app-basic fixture: gestureTransition only controls the method attachment; nothing else branches on it.

One additional non-blocking note on test robustness below. No correctness or security issues found.


await expect(page.getByTestId("static-content")).toHaveText("This is static content");

expect(page.url()).toContain(`${ROUTE_ROOT}/target-page`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (test robustness): this asserts the positive intermediate state (target page + static content visible, URL updated) during the gesture, but never asserts the negative — that the page is still in its intermediate/held state and dynamic-content has not yet resolved. target-page/page.tsx wraps DynamicContent (which await connection()) in <Suspense fallback={loading}> precisely so the dynamic hole is the distinguishing signal between the optimistic gesture commit and the final canonical push.

If connection() resolves quickly in the preview server, the gesture commit and the later router.push() would render identically, and this test could pass even if the synchronous-commit path silently degraded to a normal deferred transition. Consider asserting the held state during the gesture before clicking end-gesture, e.g.:

Suggested change
expect(page.url()).toContain(`${ROUTE_ROOT}/target-page`);
expect(page.url()).toContain(`${ROUTE_ROOT}/target-page`);
// The gesture commit is the intermediate (held) state: the dynamic hole
// should still be suspended until the canonical push resolves it. This is
// what distinguishes the synchronous gesture commit from a normal push.
await expect(page.getByTestId("loading")).toBeVisible();
await expect(page.getByTestId("dynamic-content")).toHaveCount(0);

This matches the upstream fixture's intent (the Suspense boundary exists to observe the intermediate state) and guards against the synchronous lane regressing without detection.

@ask-bonk

ask-bonk Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully.

Summary

This PR adds the experimental experimental_gesturePush() App Router API gated behind experimental.gestureTransition. I traced the full change end-to-end and found no correctness or security issues — it's a clean, well-scoped, faithful port of the upstream Next.js v16.2.6 gesture-transition deploy test.

Verified correct (re-confirming the two prior /bigbonk reviews):

  • The define emits a raw boolean literal, so the module-top guard tree-shakes away when disabled (and notably uses the boolean form, not the truthy "false" string form the sibling defines use).
  • The synchronous commit branch is reachable because gesture push uses programmaticTransition = falsependingRouterState stays null.
  • experimental_gesturePush surfaces on both useRouter() and window.next.router via the shared singleton.
  • Config resolution, default, and the NextConfig.experimental index-signature preservation all check out.
  • Enabling the flag on the shared app-basic fixture has no collateral effect.

One additional non-blocking observation I posted (not raised by prior reviews): the e2e spec asserts the positive intermediate state during the gesture but never asserts the negative — that dynamic-content is still suspended (the loading fallback visible). Since the Suspense/connection() hole is the distinguishing signal between the optimistic gesture commit and the canonical push, the test could pass even if the synchronous-commit path silently degraded to a normal transition. I suggested adding a held-state assertion before clicking "End Gesture".

github run

@ask-bonk

ask-bonk Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

@james-elicx Bonk workflow was cancelled.

View workflow run · To retry, trigger Bonk again.

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: experimental gesture push

Traced the change end-to-end. It is correct, well-scoped, and a faithful port of test/e2e/app-dir/gesture-transitions (v16.2.6). The config plumbing, define injection (raw boolean literal → guard tree-shakes when disabled), gated public method, and the new synchronous/flushSync commit lane are all internally consistent. I independently confirmed:

  • experimental_gesturePush is attached to the shared _appRouter/appRouterInstance singleton, which is fed to both AppRouterContext.Provider and installWindowNext({ router: appRouterInstance }) (passed by reference), so it surfaces on both useRouter() and window.next.router.
  • The synchronous branch is reachable: gesture push uses programmaticTransition = false, so pendingRouterState stays null and the early return at controller:521 does not win. The new comment documenting that implicit invariant is accurate.
  • flushSync fires after the RSC await (in a microtask continuation), outside any React render phase, so it is safe.
  • URL-safety / isServer ordering matches push/replace, and the throwing calls run before trackScheduledAppRouterNavigation(), so there is no scheduled-navigation leak on a synchronous throw.
  • The NextConfig.experimental object type retains its inner [key: string]: unknown, so other experimental.* keys still type-check.
  • Enabling the flag on the shared app-basic fixture only controls the method attachment; nothing else branches on it.

The concerns raised by the prior /bigbonk rounds (scheduled-navigation tracking, the synchronous-mode invariant comment, the unit test) are all resolved in this diff. One non-blocking test-robustness item remains.


await expect(page.getByTestId("static-content")).toHaveText("This is static content");

expect(page.url()).toContain(`${ROUTE_ROOT}/target-page`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (test robustness): the test asserts the positive intermediate state (target page + static content visible, URL updated) during the gesture, but never asserts the negative — that the page is still held and dynamic-content has not yet resolved. target-page/page.tsx deliberately wraps DynamicContent (which await connection()) in <Suspense fallback={loading}> so the dynamic hole is the distinguishing signal between the optimistic gesture commit and the final canonical push.

If connection() resolves quickly in the preview server, the gesture commit and the later router.push() would render identically, and this test could pass even if the synchronous-commit lane silently degraded to a normal deferred transition. Consider asserting the held state during the gesture before clicking end-gesture:

Suggested change
expect(page.url()).toContain(`${ROUTE_ROOT}/target-page`);
expect(page.url()).toContain(`${ROUTE_ROOT}/target-page`);
// The gesture commit is the intermediate (held) state: the dynamic hole
// should still be suspended until the canonical push resolves it. This is
// what distinguishes the synchronous gesture commit from a normal push.
await expect(page.getByTestId("loading")).toBeVisible();
await expect(page.getByTestId("dynamic-content")).toHaveCount(0);

@ask-bonk

ask-bonk Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully.

Summary

I traced this PR end-to-end and found no correctness or security issues. It's a clean, well-scoped, faithful port of the upstream Next.js v16.2.6 gesture-transition deploy test.

Verified correct:

  • The define emits a raw boolean literal (JSON.stringify(nextConfig.gestureTransition)), so the module-top if (process.env.__NEXT_GESTURE_TRANSITION) guard tree-shakes out when disabled.
  • experimental_gesturePush is attached to the shared _appRouter/appRouterInstance singleton, which is fed by reference to both AppRouterContext.Provider and installWindowNext, so it surfaces on both useRouter() and window.next.router.
  • The new synchronous/flushSync controller branch is reachable (gesture uses programmaticTransition = falsependingRouterState stays null), and flushSync runs in a microtask after the RSC await, outside any render phase — safe.
  • URL-safety ordering matches push/replace; no scheduled-navigation leak on a synchronous throw.
  • The NextConfig.experimental index signature is preserved; enabling the flag on the shared app-basic fixture has no collateral effect.
  • The concerns from the prior three /bigbonk rounds (scheduled-navigation tracking, the invariant comment, the unit test) are all resolved in this diff.

One non-blocking inline note posted: the e2e spec asserts the positive intermediate state during the gesture but never the negative — that dynamic-content is still suspended (the loading fallback visible). Since that Suspense hole is the distinguishing signal between the optimistic gesture commit and the canonical push, the test could pass even if the synchronous lane silently degraded to a normal transition. I suggested adding a held-state assertion before clicking "End Gesture".

github run

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: experimental gesture push

Traced the change end-to-end against the upstream v16.2.6 source. The implementation is correct, well-scoped, and faithful. I verified the items the prior /bigbonk rounds raised and have nothing new that blocks merge. Two notes below, both non-blocking.

Faithful port — the recurring "add a negative held-state assertion" suggestion should NOT be applied. I fetched the upstream test (test/e2e/app-dir/gesture-transitions/gesture-transitions.test.ts, v16.2.6). It asserts only the positive intermediate state (target page + static content visible, URL updated) and never asserts the held/suspended state — identical to this port. The earlier review rounds repeatedly suggested adding loading visible / dynamic-content absent assertions, but that would go beyond upstream and depends on FreshnessPolicy.Gesture shell behavior, which this PR explicitly lists as a non-goal. Declining that suggestion (and documenting why in the test comment) is the right call. The test still has teeth: the layout calls experimental_gesturePush(href) then awaits inside startTransition, so the target-page-visible assertion before end-gesture would fail if the method were a no-op or an ordinary transition-lane push (which would stay held until the pending promise resolves).

Behavioral parity with upstream gesturePush confirmed:

  • Upstream resolves the href via new URL(addBasePath(href), location.href) then if (isExternalURL(url)) return. vinext's toSameOriginAppPath-gated no-op produces the same observable result: same-origin absolute hrefs navigate, external/protocol-relative hrefs no-op.
  • Upstream blocks javascript: via isJavaScriptURLString; vinext's assertSafeNavigationUrl is stricter (also data:/vbscript:), matching vinext's own push/replace/prefetch. Consistent defense-in-depth.

Verified: config resolution + default, the raw-boolean define tree-shaking out the module-top guard when disabled, the synchronous/flushSync branch reachability (gesture uses programmaticTransition = false), and attachment to the shared appRouterInstance singleton surfacing on both useRouter() and window.next.router. The targeted next-config and shims unit tests pass locally.

No correctness or security issues found.

);
defines["process.env.__NEXT_GESTURE_TRANSITION"] = JSON.stringify(
nextConfig.gestureTransition,
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (clarity): this define intentionally emits a raw boolean literal (JSON.stringify(true) -> true), unlike the sibling defines a few lines up that stringify ? "true" : "false". That distinction matters: the consumer is if (process.env.__NEXT_GESTURE_TRANSITION) in shims/navigation.ts, so the boolean form is what lets the guard tree-shake when disabled — the string "false" would be truthy. This matches Next.js's own define-env.ts, but the divergence from the neighboring defines is easy to "fix" by mistake later. Worth a one-line comment so a future reader doesn't normalize it to the sibling pattern:

Suggested change
);
// Emit a raw boolean (not the "true"/"false" string form used by the
// sibling defines above): the consumer guards with
// `if (process.env.__NEXT_GESTURE_TRANSITION)`, so the literal `false`
// tree-shakes the gesture method away when disabled, whereas the
// string "false" would be truthy. Matches Next.js define-env.ts.
defines["process.env.__NEXT_GESTURE_TRANSITION"] = JSON.stringify(
nextConfig.gestureTransition,
);


expect(page.url()).toContain(`${ROUTE_ROOT}/target-page`);

// Note: like the upstream test, this deliberately does not assert the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: the phrase "like the upstream test" is accurate (I confirmed the v16.2.6 test also asserts only the positive state), but the reason given here ("connection() resolves immediately on the dev server") is vinext-specific, not the upstream reason. Upstream omits the held-state assertion because observing the suspended dynamic hole would require FreshnessPolicy.Gesture shell behavior, which is a stated non-goal of this PR. Consider tightening the comment so it doesn't imply upstream omitted it for the same dev-server timing reason — otherwise this reads as if both tests share a flakiness rationale when really vinext is matching upstream's intentional scope.

@ask-bonk

ask-bonk Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully.

Review Summary

I traced PR #1909 end-to-end against the upstream Next.js v16.2.6 source and found no correctness or security issues. The implementation is clean, well-scoped, and faithful. Posted one review with two non-blocking inline notes.

Key finding (resolving the recurring /bigbonk thread): The four prior review rounds repeatedly suggested adding a "negative held-state" assertion (loading visible / dynamic-content absent) to the e2e test. I fetched the actual upstream test and confirmed it asserts only the positive intermediate state — exactly like this port. Adding the negative assertion would go beyond upstream and depends on FreshnessPolicy.Gesture shell behavior that the PR explicitly lists as a non-goal. The author correctly declined it. The test still has teeth because the layout awaits inside startTransition, so the target-page-visible assertion before end-gesture would fail for a no-op or ordinary transition-lane push.

Verified correct:

  • Behavioral parity with upstream gesturePush (same-origin absolute hrefs navigate, external no-op; javascript: blocked, with vinext being stricter on data:/vbscript: consistent with its own push/replace).
  • Raw-boolean define correctly tree-shakes the module-top guard when disabled.
  • synchronous/flushSync branch reachability and the documented null-pendingRouterState invariant.
  • Attachment to the shared appRouterInstance surfacing on both useRouter() and window.next.router.
  • Targeted next-config and shims unit tests pass locally.

Two non-blocking notes posted:

  1. index.ts — suggest a comment explaining why the gesture define uses a raw boolean (vs the sibling "true"/"false" strings), so it isn't "normalized" by mistake later.
  2. Test comment — the "like the upstream test" claim is accurate, but the stated reason (dev-server connection() timing) is vinext-specific; upstream omits it for the FreshnessPolicy.Gesture non-goal reason.

github run

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: experimental gesture push

Traced the change end-to-end and ran the targeted suites locally (vp test run tests/shims.test.ts -t experimental_gesturePush, vp test run tests/next-config.test.ts -t gestureTransition, vp check on the four core source files) — all green. The implementation is correct, well-scoped, and a faithful port of upstream test/e2e/app-dir/gesture-transitions (v16.2.6). No blocking issues.

Independently re-verified the key invariants (matching the prior /bigbonk rounds):

  • Surface area: useRouter() returns React.useContext(AppRouterContext), and the provider value is appRouterInstance (= _appRouter) at app-browser-entry.ts:1098 / app-ssr-entry.ts:391, and installWindowNext({ router: appRouterInstance }) at app-browser-entry.ts:2264. So the gated method surfaces on both useRouter() and window.next.router from the same singleton.
  • synchronous branch reachability: gesture push calls navigateClientSide(..., programmaticTransition=false, "synchronous"); shouldUsePendingRouterState = programmaticTransition (app-browser-entry.ts:1673) so pendingRouterState stays null and the early return at controller:521 does not win — the flushSync branch at controller:541 is reached.
  • Tree-shaking: the define emits a raw boolean (JSON.stringify(nextConfig.gestureTransition)), so the module-top if (process.env.__NEXT_GESTURE_TRANSITION) guard drops the method when disabled. The inline comment in index.ts explaining the divergence from the sibling "true"/"false" defines is a good guard against accidental "normalization."
  • Scheduled-navigation parity: experimental_gesturePush uses the same trackScheduledAppRouterNavigation() + releaseScheduledAppRouterNavigationAfterCurrentTask() (microtask) window as push/replace, and the throwing calls (assertSafeNavigationUrl, same-origin normalization) run before tracking — so no scheduled-navigation leak on a synchronous throw.
  • Config + types: default false; experimental?.gestureTransition === true coerces safely; NextConfig.experimental retains its inner [key: string]: unknown so other experimental.* keys still type-check.

One small forward-looking note inline. Otherwise this is good to merge.

import type { NextConfig } from "vinext";

const nextConfig: NextConfig = {
experimental: {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (test-suite scope): enabling experimental.gestureTransition on the shared app-basic fixture turns the gate on for every app-router e2e/integration test that runs against this fixture, not just the gesture spec. I confirmed the only observable effect is the optional experimental_gesturePush method being attached (nothing else branches on the flag), so this is safe today. The latent risk is that if a future test asserts the absence of the method (to verify the off-path of the gate), it would now silently pass against this fixture because the flag is globally on. Worth a one-line comment here noting the flag is enabled fixture-wide solely for the gesture-transitions spec, so a future reader doesn't assume the fixture represents the default (gate-off) config.

@ask-bonk

ask-bonk Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully.

Review Summary

I traced PR #1909 end-to-end and ran the targeted test suites locally — all green. The implementation is correct, well-scoped, and a faithful port of upstream Next.js v16.2.6's gesture-transition deploy test. No blocking issues.

Independently verified:

  • experimental_gesturePush surfaces on both useRouter() and window.next.router — confirmed by tracing useRouter()React.useContext(AppRouterContext), and that the provider value is the appRouterInstance/_appRouter singleton at app-browser-entry.ts:1098 and app-ssr-entry.ts:391.
  • The synchronous/flushSync controller branch is reachable: gesture uses programmaticTransition = falsependingRouterState stays null → the early return at controller:521 does not win.
  • The define emits a raw boolean so the module-top guard tree-shakes the method away when disabled.
  • Scheduled-navigation tracking matches push/replace (same microtask release window; throwing calls run before tracking → no leak on a synchronous throw).
  • Config default + type index-signature preservation check out.
  • vp check on the four core files, plus the new shims.test.ts and next-config.test.ts cases, all pass.

One non-blocking inline note posted (not raised in the six prior /bigbonk rounds): enabling experimental.gestureTransition: true on the shared app-basic fixture turns the gate on for every app-router test against that fixture. It's safe today since nothing else branches on the flag, but it means a future test asserting the method's absence would silently pass — worth a one-line comment that the flag is enabled fixture-wide solely for the gesture spec.

The recurring "add a negative held-state assertion to the e2e test" suggestion from earlier rounds was correctly declined (it would exceed upstream scope and depend on FreshnessPolicy.Gesture behavior, an explicit non-goal). Good to merge.

github run

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: experimental gesture push

Traced the change end-to-end against upstream Next.js v16.2.6 (app-router-instance.ts gesturePush and gesture-transitions.test.ts). The config plumbing, raw-boolean define, gated public method, and the new synchronous/flushSync commit lane are correct and internally consistent. The seven prior /bigbonk rounds covered the main invariants (singleton attachment surfacing on useRouter() + window.next.router, synchronous branch reachability via programmaticTransition = false, scheduled-navigation parity, tree-shaking, NextConfig.experimental index-signature preservation). I re-verified those and they hold. No blocking issues.

One structural note worth recording for future readers: vinext's experimental_gesturePush and upstream's diverge in mechanism, not just internals. Upstream forks an optimistic, discardable router state via dispatchGestureState (FreshnessPolicy.Gesture) and does not push history or run a dynamic request during the gesture — the URL/canonical state only lands when the held router.push() resolves. vinext instead performs a real RSC navigation that commits to history synchronously during the gesture, and then the fixture's canonical router.push(href) navigates to the same URL a second time. This reproduces the upstream test's observable contract (target visible + URL updated mid-gesture) but is a meaningfully different runtime path (double navigation to the same href, no optimistic-state discard). The PR documents this as a deliberate non-goal, so it's an acceptable, intentional divergence — flagging it so it's not mistaken for a faithful internal port later.

Two minor, non-blocking notes inline.

}

function formatPublicBfcacheId(value: string | null | undefined): string {
if (!value || value === INITIAL_BFCACHE_ID) return PUBLIC_INITIAL_BFCACHE_ID;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (pre-hydration parity): upstream gesturePush early-returns when getCurrentAppRouterState() === null (gesture fired before hydration is a no-op). Here, if the navigation runtime isn't ready yet, navigateClientSide falls through to its non-runtime branch and performs a hard pushHistoryStateWithoutNotify(...) + commitClientNavigationState() — a real history push rather than a no-op. This matches vinext's existing push/replace fallback so it's consistent, but it does differ from upstream's gesture-specific guard. Only reachable if a gesture is dispatched before hydration, which the fixture never does, so not blocking.

void navigateClientSide(appHref, "push", options?.scroll !== false, false, "synchronous");
releaseScheduledAppRouterNavigationAfterCurrentTask(releaseNavigation);
};
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (error handling): void navigateClientSide(...) is fire-and-forget with no .catch. If the RSC navigation rejects (fetch failure mid-gesture), the rejection surfaces as an unhandled promise rejection. push/replace have the same shape (the void navigateClientSide(...) runs inside React.startTransition, also un-awaited), so this is consistent with existing behavior — just noting that the gesture path inherits the same un-awaited-rejection characteristic without the surrounding try/catch that push/replace use for the synchronous startTransition throw.

@ask-bonk

ask-bonk Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully.

Summary

I traced PR #1909 end-to-end against upstream Next.js v16.2.6 and found no blocking correctness or security issues. The implementation is clean and well-scoped. I confirmed the invariants the seven prior /bigbonk rounds raised all hold (singleton attachment, synchronous branch reachability, scheduled-navigation parity, raw-boolean tree-shaking, config-type preservation).

One structural finding worth recording (non-blocking): vinext's experimental_gesturePush diverges from upstream in mechanism, not just internals. Upstream forks an optimistic, discardable router state via dispatchGestureState (FreshnessPolicy.Gesture) and does not push history during the gesture. vinext instead performs a real RSC navigation that commits to history synchronously, then the fixture's canonical router.push() re-navigates to the same URL. This reproduces the upstream test's observable contract but is a different runtime path (double navigation, no optimistic discard). Since the PR explicitly lists the React Gesture Transition internals as a non-goal, this is a deliberate, acceptable divergence — I flagged it so it isn't mistaken for a faithful internal port later.

Two minor inline notes (both consistent with existing push/replace behavior, not regressions):

  1. Pre-hydration parity: upstream no-ops when router state is null; vinext falls through to a hard history push if the navigation runtime isn't ready (unreachable from the fixture).
  2. void navigateClientSide(...) is fire-and-forget without a .catch, inheriting the same un-awaited-rejection shape as push/replace.

No code was changed — this is a review-only assessment.

github run

…time is ready

Upstream gesturePush early-returns when getCurrentAppRouterState() is null,
so a gesture dispatched before hydration is a no-op. Previously vinext fell
through navigateClientSide's non-runtime branch and performed a real history
push. Guard on the runtime's navigate function (the same readiness check
navigateClientSide uses) and document that the un-awaited
void navigateClientSide(...) deliberately matches push/replace's
fire-and-forget rejection shape.
@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: experimental gesture push

Traced the change end-to-end. The implementation is correct, well-scoped, and consistent with the existing push/replace/prefetch patterns in shims/navigation.ts. No blocking issues. The concerns from the eight prior /bigbonk rounds (singleton attachment surfacing on both useRouter() and window.next.router, synchronous-branch reachability via programmaticTransition = false, scheduled-navigation parity, raw-boolean tree-shaking, NextConfig.experimental index-signature preservation, the inline guard comments, and the unit tests) are all resolved in this diff. I re-verified them and they hold.

Independently confirmed details not previously called out:

  • basePath round-trip is correct. For an absolute same-origin href, experimental_gesturePush calls toSameOriginAppPath(href, __basePath) which strips basePath (url-utils.ts:69), then hands the stripped appHref to navigateClientSide, which re-applies it via toBrowserNavigationHref(..., __basePath) (navigation.ts:1791). This matches prefetch's flow exactly, so basePath apps don't double-apply or drop the prefix.
  • isExternalUrl === isAbsoluteOrProtocolRelativeUrl (navigation.ts:1562-1564), so the inline external no-op in the gesture path and the isExternalUrl check inside navigateClientSide agree — no classification skew.
  • useRouter() returns { ...router, bfcacheId } (navigation.ts:2140), and the method is mutated onto the singleton at module-load before any render, so the spread always includes it when enabled.
  • Type/runtime alignment: AppRouterInstance.experimental_gesturePush?(href, options?: NavigateOptions) with NavigateOptions = { scroll?: boolean } matches the implementation's options?: { scroll?: boolean }.

One minor, non-blocking observation inline.

// navigate function — the same check navigateClientSide uses before its
// non-runtime fallback, which would otherwise perform a real history push
// here instead of upstream's no-op.
if (!getNavigationRuntime()?.functions.navigate) return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (parity nuance): the readiness guard checks getNavigationRuntime()?.functions.navigate, but the body actually only reads getNavigationRuntime() again indirectly through navigateClientSide, which re-fetches the runtime. If the runtime is registered between this guard and the microtask where navigateClientSide's appNavigate = getNavigationRuntime()?.functions.navigate lookup runs, the two reads could see different states. In practice both run synchronously up to the first await in navigateClientSide (the appNavigate lookup at navigation.ts:1847 happens before any await), so there's no real TOCTOU window here, and push/replace have the same shape. Just noting the guard and the actual dispatch read the runtime separately, which is fine but worth keeping in mind if the runtime registration ever becomes async.

@ask-bonk

ask-bonk Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully.

Summary

I traced PR #1909 end-to-end and found no blocking correctness or security issues. The implementation cleanly adds the experimental experimental_gesturePush() App Router API gated behind experimental.gestureTransition, consistent with the existing push/replace/prefetch patterns and faithful to upstream Next.js v16.2.6's scope.

This PR has already been through eight prior /bigbonk review rounds; all of those concerns (singleton attachment, synchronous-branch reachability, scheduled-navigation parity, raw-boolean tree-shaking, type preservation, guard comments, unit tests) are resolved in the current diff, and I re-verified them.

Details I independently confirmed that hadn't been called out before:

  • basePath round-trip is correcttoSameOriginAppPath strips basePath, navigateClientSide re-applies it via toBrowserNavigationHref, matching prefetch's flow (no double-apply or drop).
  • isExternalUrl === isAbsoluteOrProtocolRelativeUrl, so the inline external no-op and the check inside navigateClientSide agree (no classification skew).
  • useRouter() spread includes the method since it's mutated onto the singleton at module-load before any render.
  • Type/runtime alignment between AppRouterInstance.experimental_gesturePush and the implementation's options shape.

One non-blocking inline note: the readiness guard reads the navigation runtime separately from navigateClientSide's dispatch read — no real TOCTOU window today (both run synchronously up to the first await), but worth keeping in mind if runtime registration ever becomes async.

Good to merge.

github run

@james-elicx

Copy link
Copy Markdown
Member

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Review: experimental gesture push

Independently traced the change end-to-end. No blocking correctness or security issues. The config plumbing, raw-boolean define, gated public method, and the new synchronous/flushSync commit lane are all internally consistent, and I re-verified the core invariants from the prior /bigbonk rounds:

  • Surface area: experimental_gesturePush is attached to the _appRouter/appRouterInstance singleton at module load (before any render), and that singleton is passed by reference to AppRouterContext.Provider (app-browser-entry.ts:1098, app-ssr-entry.ts:391) and to installWindowNext({ router: appRouterInstance }) (assigned by reference at window-next.ts:189). So it surfaces on both useRouter() (via the ...router spread) and window.next.router.
  • synchronous branch reachability: gesture push calls navigateClientSide(..., programmaticTransition=false, "synchronous"). shouldUsePendingRouterState = programmaticTransition (app-browser-entry.ts:1673), so pendingRouterState stays null and the early return at controller:521 does not win — the flushSync branch at controller:541 is reached.
  • Tree-shaking: JSON.stringify(nextConfig.gestureTransition) emits a raw boolean literal, so if (process.env.__NEXT_GESTURE_TRANSITION) drops the method when disabled. The inline comment in index.ts:1324 correctly documents the divergence from the sibling "true"/"false" string defines.
  • Throw ordering / scheduled-nav parity: assertSafeNavigationUrl and the same-origin normalization (both of which can return/throw) run before trackScheduledAppRouterNavigation(), so there is no scheduled-navigation leak on a synchronous throw — consistent with push/replace.
  • Config/types: default false; experimental?.gestureTransition === true coerces safely; NextConfig.experimental retains its inner [key: string]: unknown.

The items from the earlier rounds (scheduled-nav tracking, the invariant comment, the unit test, the fixture-scope comment, the index.ts define comment) are all resolved in this diff.

One substantive note below (non-blocking, already a documented non-goal).

router.experimental_gesturePush(href);

await new Promise<void>((resolve) => {
pendingResolvers.add(resolve);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking, but worth recording explicitly for future readers: vinext's experimental_gesturePush performs a real RSC navigation with a synchronous history push (navigateClientSide(..., "push", ...)), and then this fixture's canonical router.push(href) navigates to the same URL a second time. Upstream's gesture path is mechanically different — it forks an optimistic, discardable router state (FreshnessPolicy.Gesture) and does not push history during the gesture; the URL only lands when the held push() resolves.

The observable contract the deploy test checks (target visible + URL updated mid-gesture) is reproduced, and the PR lists the React Gesture Transition internals as an explicit non-goal, so this is an acceptable, deliberate divergence. The one place it could leak into observable behavior is history/back-button state: two push-mode navigations to the same href. If navigateRsc's commit dedups same-URL pushes into a replace this is a no-op; if it doesn't, a gesture-then-canonical-push leaves a duplicate history entry that upstream would not. Worth confirming the back-button lands on Home (not target-page twice) if/when a follow-up adds traversal coverage — not required for this PR.

@ask-bonk

ask-bonk Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Review posted successfully.

Summary

I independently traced PR #1909 end-to-end and found no blocking correctness or security issues. The implementation cleanly adds the experimental experimental_gesturePush() App Router API gated behind experimental.gestureTransition.

Verified correct:

  • experimental_gesturePush is attached to the appRouterInstance singleton at module load and surfaces on both useRouter() (via the ...router spread) and window.next.router (both fed by reference).
  • The synchronous/flushSync controller branch is reachable: gesture uses programmaticTransition = falsependingRouterState stays null → early return at controller:521 doesn't win.
  • The define emits a raw boolean literal, so the module-top guard tree-shakes the method away when disabled.
  • URL-safety/normalization throws run before trackScheduledAppRouterNavigation(), so no scheduled-navigation leak on a synchronous throw — consistent with push/replace.
  • Config default, === true coercion, and NextConfig.experimental index-signature preservation all check out.
  • All concerns from the prior /bigbonk rounds are resolved in the current diff.

One substantive non-blocking note posted (not previously raised at this depth): vinext's gesture path does a real RSC navigation with a synchronous history push, then the fixture's canonical router.push() re-navigates to the same URL — two push-mode commits to the same href. Upstream instead forks an optimistic, discardable state and pushes no history during the gesture. The observable test contract is reproduced and the divergence is an explicit non-goal, so it's acceptable — but the one place it could leak is back-button/history state (potential duplicate history entry if navigateRsc doesn't dedup same-URL pushes into a replace). Worth confirming in any future traversal coverage.

This is good to merge.

github run

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.

2 participants