feat(shims): add useUntrackedPathname hook for error boundary parity - #1933
Conversation
Next.js upstream provides a useUntrackedPathname() hook (packages/next/src/client/components/navigation-untracked.ts) that is used by the unstable_catchError error boundary. Unlike usePathname(), useUntrackedPathname(): - Returns null during the missing-params shell prerender (instead of "/") - Does not register the pathname as a tracked render dependency, avoiding unnecessary re-renders of the error boundary This commit: 1. Adds useUntrackedPathname() to packages/vinext/src/shims/navigation.ts 2. Switches packages/vinext/src/shims/error.tsx to use it 3. Adds unit tests covering server, client snapshot, and Pages Router paths Refs: - Next.js source: https://github.com/vercel/next.js/blob/v16.2.6/packages/next/src/client/components/navigation-untracked.ts - Issue: cloudflare#1920
commit: |
Address code review feedback on PR 1933: - Move useUntrackedPathname from public next/navigation API to internal/navigation-untracked.ts to prevent public surface leak. - Change server no-context fallback from null to '/' to match usePathname() semantics. Document that null is reserved for future missing-params shell detection. - Update error.tsx to import from the internal module. - Update shims tests to import from internal module and expect '/' for no-context SSR. - Add integration test for unstable_catchError boundary contract: getDerivedStateFromProps clears errors on pathname changes and retains them when pathname stays the same.
Address second round of code review feedback on PR 1933: - Gate client render snapshot with navigationSnapshotActiveCount > 0 so the hook returns the pending URL only during active transitions. After commit, falls back to cached pathname so user pushState/ replaceState calls are immediately reflected. - Import getNavigationContext() from navigation.ts instead of duplicating the global accessor / hydration context logic. - Add inverse test: provider snapshot present but inactive (navigationSnapshotActiveCount === 0) returns committed pathname, not stale provider value.
Export _CatchError, _CatchErrorInternalState, _CatchErrorProps, _CatchErrorState, and _UserProps from error.tsx with @internal annotations so tests can reference them without as-unknown-as assertions. Rewrite the getDerivedStateFromProps integration test to use imported types instead of inline type assertions.
Address review feedback: exporting _CatchError and underscore types from next/error widened the public shim surface. Revert them to local types and use structural typing in tests instead. - Make _CatchError, _UserProps, _CatchErrorState, _CatchErrorProps, and _CatchErrorInternalState local again (non-exported). - Update the getDerivedStateFromProps integration test to use structural as-unknown-as assertions instead of importing internal types.
Create packages/vinext/src/shims/internal/pages-router-accessor.ts with getPagesNavigationContext() and PagesNavigationContext type. Import from it in both navigation.ts and navigation-untracked.ts instead of duplicating the Symbol lookup and error handling. This prevents the string key and the accessor shape from drifting across modules if a third consumer is added later. router.ts still writes the global accessor directly using Symbol.for as before.
Move the import from the middle of the file to the top import block and remove the unused PagesNavigationContext type import. Fixes eslint no-unused-vars and import/first errors.
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: useUntrackedPathname for error boundary parity
Solid, well-scoped change. I verified parity against Next.js v16.2.6's navigation-untracked.ts, ran vp check on the four changed source files (clean), and ran the full tests/shims.test.ts suite (1055 passed). Findings below are minor / non-blocking.
Verified correct
- No conditional-hook bug.
isServeris a module-level constant, so the server branch (no hooks) and client branch (always oneuseContextviauseClientNavigationRenderSnapshot) each keep a stable hook count per environment — same invariant Next.js relies on (hasFallbackRouteParams()always returnsfalseon the client, souseContextis unconditional there). - Active-navigation snapshot gate matches
usePathname/useSearchParams/useParams(navigationSnapshotActiveCount > 0), so the hook returns the pending URL mid-transition and the committedcachedPathnamepost-commit. This is the right behavior for thegetDerivedStateFromPropsreset contract. getNavigationContext()===_getServerContext(), so swapping to the public export preserves the server path used byusePathname.- Accessor refactor is sound. The new
pages-router-accessor.tsuses the sameSymbol.for("vinext.navigation.pagesNavigationContextAccessor")key registered inrouter.ts:2489, andnavigation.tskeeps all call sites via the aliased import. Type shapes (PagesNavigationContextvsPagesNavigationContextShape) are structurally identical. - Boundary contract tests are accurate — null→real pathname clears the error per
_CatchError.getDerivedStateFromProps(error.tsx:172).
Minor suggestions (non-blocking)
useClientNavigationRenderSnapshotis now duplicated verbatim betweennavigation.ts(module-private, line 1330) and the new module. Two copies of the same hook-internals is a drift risk if the snapshot-context handling ever changes. Consider exporting the existing one fromnavigation.tsand importing it here instead.- The doc comment's
"/"server fallback is an intentional divergence from Next.js (whoseuseContext(PathnameContext)default isnull). It's consistent with vinext's ownusePathname, so it's fine — just worth a one-line note that this is deliberate parity-with-usePathnamerather than parity-with-Next.js, to avoid a future reader "fixing" it.
No correctness or security concerns. LGTM once you've considered the dedup suggestion.
|
Review posted successfully to PR #1933. SummaryI reviewed PR #1933 ( Validated:
Correctness confirmed:
Two minor, non-blocking notes:
No correctness or security concerns found. |
|
/bigbonk review for issues |
There was a problem hiding this comment.
Review: useUntrackedPathname for error boundary parity
Verified the change against Next.js v16.2.6's navigation-untracked.ts, ran vp check on all four changed source files (clean), and ran the new tests (useUntrackedPathname + getDerivedStateFromProps) — all green.
Verified correct
- Hook-count stability.
isServeris a module-level constant; the client branch always calls exactly one hook (useClientNavigationRenderSnapshot→React.useContext), the server branch calls none. Each environment keeps a stable hook count, matching the upstream invariant. - No
useSyncExternalStoresubscription. This is the whole point of the hook (avoiding re-renders on navigation), and the implementation correctly omits it while still returning a pathname that updates on navigation. - Active-navigation gate (
navigationSnapshotActiveCount > 0) matches the existingusePathname/useSearchParams/useParamsinvariant — returns pending URL mid-transition, committed pathname post-commit. This is exactly what thegetDerivedStateFromPropsreset contract needs. - Symbol parity.
pages-router-accessor.tsuses the sameSymbol.for("vinext.navigation.pagesNavigationContextAccessor")registered inrouter.ts:2486, and the extracted type is structurally identical to the old inline one. getDerivedStateFromPropscontract tests accurately reflecterror.tsx:172(clear on pathname change, retain on same pathname, clear on null→real).
Note on prior review
The earlier ask-bonk review flagged useClientNavigationRenderSnapshot as "duplicated verbatim" — that concern is now resolved. The PR exports the existing function from navigation.ts:1331 and imports it in the new module; there is only one definition. No action needed.
Minor (non-blocking)
One small redundancy in the client fall-through path — see inline comment. Not a correctness issue.
No correctness or security concerns. LGTM.
| const pagesCtx = getPagesNavigationContext(); | ||
| if (pagesCtx) return pagesCtx.pathname; | ||
| return getPathnameSnapshot(); |
There was a problem hiding this comment.
Minor: the explicit Pages-context check here is redundant. getPathnameSnapshot() (lines 22-26) already calls getPagesNavigationContext() first and returns pagesCtx.pathname when present, so lines 64-65 duplicate that work. You can collapse to a single call:
| const pagesCtx = getPagesNavigationContext(); | |
| if (pagesCtx) return pagesCtx.pathname; | |
| return getPathnameSnapshot(); | |
| return getPathnameSnapshot(); |
Harmless either way (the extra lookup is cheap and side-effect-free), but removing it keeps this hook's client fall-through aligned with usePathname, which relies on getPathnameSnapshot alone for the same logic.
|
Review posted successfully to PR #1933. SummaryI reviewed PR #1933 ( Verification performed:
Correctness confirmed:
Notable finding — prior review was stale: The earlier One minor, non-blocking suggestion: Lines 64-65 of No correctness or security concerns. LGTM. |
Summary
Next.js upstream provides a
useUntrackedPathname()hook (inpackages/next/src/client/components/navigation-untracked.ts) that is used by theunstable_catchErrorerror boundary. UnlikeusePathname(),useUntrackedPathname():pushState/replaceStatechanges are immediately reflectedWhat changed
Added
useUntrackedPathname()topackages/vinext/src/shims/internal/navigation-untracked.ts(internal module, not exported from publicnext/navigation)/when no navigation context is available (the client will hydrate with the real value). Returnsnullonly for missing-params shell prerenders — vinext does not yet implement fallback-route-param detection, so this path is not currently reachable.navigationSnapshotActiveCount > 0), matching the existing invariant used byusePathname(),useSearchParams(), anduseParams(). After commit, falls back to the cached pathname.Switched
error.tsxto import and useuseUntrackedPathname()instead ofusePathname()in theunstable_catchErrorwrapper.Added tests covering:
/fallback when no context is setgetDerivedStateFromPropsboundary contract: clears errors on pathname changes, retains them when pathname stays the sameReferences
Closes #1920