@uniweb/runtime 1.2.7 → 1.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/runtime",
3
- "version": "1.2.7",
3
+ "version": "1.2.8",
4
4
  "description": "Minimal runtime for loading Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -6,12 +6,13 @@
6
6
  * Manages head meta tags for SEO and social sharing.
7
7
  */
8
8
 
9
- import React, { useMemo, useEffect, useReducer, useRef } from 'react'
9
+ import React, { useMemo, useEffect, useLayoutEffect, useReducer, useRef } from 'react'
10
10
  import { useLocation, useNavigate } from 'react-router-dom'
11
11
  import BlockRenderer from './BlockRenderer.jsx'
12
12
  import Layout from './Layout.jsx'
13
13
  import { useHeadMeta } from '../hooks/useHeadMeta.js'
14
14
  import { useSectionViews } from '../hooks/useSectionViews.js'
15
+ import { pageCommitted } from '../navigation.js'
15
16
  import { buildSectionOverrides } from '@uniweb/theming'
16
17
  import { carryQuery } from '@uniweb/core/resolve-route'
17
18
  import { Default404 } from '../default-404.js'
@@ -206,6 +207,10 @@ export default function PageRenderer() {
206
207
 
207
208
  useHeadMeta(headMeta, { siteName })
208
209
 
210
+ // ⭐ The DOM holds the page the router committed, and the browser has not painted it yet. A view
211
+ // transition waiting on the navigation may capture it now (`navigation.js`).
212
+ useLayoutEffect(() => { pageCommitted() }, [location])
213
+
209
214
  // For dynamic pages created before collection data was available (hard reload),
210
215
  // the page title starts as the template name (e.g. '[id]'). Subscribe to
211
216
  // DataStore updates so we re-render once the collection loads — _createDynamicPage
@@ -246,10 +251,12 @@ export default function PageRenderer() {
246
251
  )
247
252
  }
248
253
 
249
- // Wait for content before rendering blocks (split page content): reached after an in-app
250
- // navigation to a page not loaded yet, and it renders nothing until the sections arrive. Not at
251
- // boot — the page a visit starts on carries its own sections (`splitPageSections`) — and never
252
- // over the prerendered HTML: the browser's first commit replaces it (`createRoot`, index.jsx).
254
+ // Wait for content before rendering blocks (split page content); nothing renders until the
255
+ // sections arrive. An in-app navigation waits for them before it reaches here
256
+ // (`navigation.js`), so this is the fallback: content slower than that wait, or a navigation made
257
+ // around the runtime's. Not at boot — the page a visit starts on carries its own sections
258
+ // (`splitPageSections`) — and never over the prerendered HTML: the browser's first commit
259
+ // replaces it (`createRoot`, index.jsx).
253
260
  if (!contentReady) return null
254
261
 
255
262
  const appearance = website?.themeData?.appearance
@@ -15,7 +15,7 @@
15
15
  import { useEffect, useCallback } from 'react'
16
16
  import { useNavigate, useLocation } from 'react-router-dom'
17
17
  import { localeOfPath } from '@uniweb/core/resolve-route'
18
- import { prefetchContent, prefersReducedMotion } from '../setup.js'
18
+ import { navigateInApp } from '../navigation.js'
19
19
 
20
20
  /**
21
21
  * Check if a URL is internal (same origin, no external protocol)
@@ -64,7 +64,7 @@ function shouldNavigate(event) {
64
64
  * @param {EventTarget} target - The event target
65
65
  * @returns {HTMLAnchorElement|null}
66
66
  */
67
- function findAnchorElement(target) {
67
+ export function findAnchorElement(target) {
68
68
  let element = target
69
69
  while (element && element !== document.body) {
70
70
  if (element.tagName === 'A') {
@@ -75,6 +75,44 @@ function findAnchorElement(target) {
75
75
  return null
76
76
  }
77
77
 
78
+ /**
79
+ * A link the runtime may route, or null: an anchor whose href stays on this site, downloads
80
+ * nothing and opens in this tab.
81
+ *
82
+ * `routeHref` is where the browser would go, as a path: the href resolved against this page's
83
+ * URL, so a query (`?tab=2`) or a relative href (`about`) is judged like any other path. The base
84
+ * path comes off, because React Router and locale detection work with router-relative paths (a
85
+ * <Link reload> carries the base).
86
+ *
87
+ * ⛔ Until 2026-10-24 an href that was not a path counted as the default locale's, so on any
88
+ * other locale's page it was a page load: a query or a relative link reloaded the page, and left
89
+ * an editor's preview frame.
90
+ *
91
+ * @param {HTMLAnchorElement|null} anchor
92
+ * @returns {{ anchor: HTMLAnchorElement, href: string, routeHref: string, website: Object }|null}
93
+ */
94
+ export function inAppLink(anchor) {
95
+ const href = anchor?.getAttribute('href')
96
+ if (!href || !isInternalLink(href)) return null
97
+ if (anchor.hasAttribute('download')) return null
98
+ const target = anchor.getAttribute('target')
99
+ if (target && target !== '_self') return null
100
+
101
+ const website = globalThis.uniweb?.activeWebsite
102
+ const basePath = website?.basePath || ''
103
+ const resolved = new URL(href, window.location.href)
104
+ let routeHref = resolved.pathname + resolved.search + resolved.hash
105
+ if (basePath && routeHref.startsWith(basePath)) {
106
+ routeHref = routeHref.slice(basePath.length) || '/'
107
+ }
108
+ return { anchor, href, routeHref, website }
109
+ }
110
+
111
+ /** Whether a page of `website` has content not fetched yet: a site whose content is split. */
112
+ function hasPagesToFetch(website) {
113
+ return !!website?.pages?.some((page) => page.hasContent() && !page.isContentLoaded())
114
+ }
115
+
78
116
  /**
79
117
  * The locale and page a link goes to when it leaves the active locale, or null.
80
118
  *
@@ -149,40 +187,9 @@ export function useLinkInterceptor(options = {}) {
149
187
  // Check if we should handle this click
150
188
  if (!shouldNavigate(event)) return
151
189
 
152
- // Find the anchor element
153
- const anchor = findAnchorElement(event.target)
154
- if (!anchor) return
155
-
156
- // Get the href
157
- const href = anchor.getAttribute('href')
158
- if (!href) return
159
-
160
- // Check if it's an internal link
161
- if (!isInternalLink(href)) return
162
-
163
- // Check for download attribute
164
- if (anchor.hasAttribute('download')) return
165
-
166
- // Check for target="_blank" or other non-self targets
167
- const target = anchor.getAttribute('target')
168
- if (target && target !== '_self') return
169
-
170
- const website = globalThis.uniweb?.activeWebsite
171
- const basePath = website?.basePath || ''
172
-
173
- // Where the browser would go, as a path: the href resolved against this page's URL, so
174
- // a query (`?tab=2`) or a relative href (`about`) is judged like any other path. The
175
- // base path comes off, because React Router and locale detection work with
176
- // router-relative paths (a <Link reload> carries the base).
177
- //
178
- // ⛔ Until 2026-10-24 an href that was not a path counted as the default locale's, so
179
- // on any other locale's page it was a page load: a query or a relative link reloaded
180
- // the page, and left an editor's preview frame.
181
- const resolved = new URL(href, window.location.href)
182
- let routeHref = resolved.pathname + resolved.search + resolved.hash
183
- if (basePath && routeHref.startsWith(basePath)) {
184
- routeHref = routeHref.slice(basePath.length) || '/'
185
- }
190
+ const link = inAppLink(findAnchorElement(event.target))
191
+ if (!link) return
192
+ const { anchor, href, routeHref, website } = link
186
193
 
187
194
  // A link to another locale's page is a page load, since that locale is its own document,
188
195
  // and the browser makes it. A host that switches locale its own way (`uniweb.localeSwitch`:
@@ -214,27 +221,33 @@ export function useLinkInterceptor(options = {}) {
214
221
  return
215
222
  }
216
223
 
217
- // Use React Router navigation (with router-relative path)
218
- // React Router will handle the path, and our useEffect above
219
- // will handle scrolling to hash after navigation completes
220
- const useTransition = website?.viewTransitions && document.startViewTransition
221
- && !prefersReducedMotion
222
-
223
- if (useTransition) {
224
- document.startViewTransition(async () => {
225
- await prefetchContent(routeHref)
226
- navigate(routeHref)
227
- })
228
- } else {
229
- navigate(routeHref)
230
- }
224
+ // React Router takes the router-relative path, and the effect above scrolls to a fragment
225
+ // once the page is there. Like a <Link>, the click holds this page until the next one's
226
+ // content has arrived, and the swap is animated when view transitions apply (`navigation.js`).
227
+ navigateInApp(navigate, routeHref)
231
228
  }
232
229
 
233
- // Add click listener to document
234
230
  document.addEventListener('click', handleClick)
235
231
 
232
+ // ⭐ A page is fetched while the reader is about to follow a link to it (`link-intent.js`),
233
+ // on a site with pages still to fetch. A site whose pages all came with it never downloads
234
+ // that module. `cancelled` covers an effect torn down before the module arrives.
235
+ let cancelled = false
236
+ let stopIntent = null
237
+ if (hasPagesToFetch(globalThis.uniweb?.activeWebsite)) {
238
+ import('../link-intent.js')
239
+ .then((m) => {
240
+ if (!cancelled) stopIntent = m.watchLinkIntent()
241
+ })
242
+ .catch(() => {
243
+ // Without it, a click still holds the page until its content arrives.
244
+ })
245
+ }
246
+
236
247
  return () => {
237
248
  document.removeEventListener('click', handleClick)
249
+ cancelled = true
250
+ if (stopIntent) stopIntent()
238
251
  }
239
252
  }, [enabled, navigate])
240
253
  }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Prefetch on intent: a link's page is fetched while the reader is about to follow it.
3
+ *
4
+ * Only a site whose content is split loads this module (`useLinkInterceptor`). A site whose
5
+ * pages all arrived with it never downloads it, so the runtime every site carries pays only for
6
+ * the decision.
7
+ *
8
+ * A pointer resting on a link, or focus staying on one, for INTENT_DELAY fetches the split
9
+ * content of the page the link goes to. A press fetches it at once, since a touch has no hover.
10
+ * The click that follows then usually finds the content here, and the page changes without
11
+ * waiting (`navigation.js`). A pointer sweeping across a rail of links fetches nothing, and
12
+ * nothing is fetched for a connection that asks to save data.
13
+ */
14
+
15
+ import { prefetchContent } from './navigation.js'
16
+ import { findAnchorElement, inAppLink, localeSwitchOf } from './hooks/useLinkInterceptor.js'
17
+
18
+ /** How long a pointer rests on a link, or focus stays on it, before its page is fetched. */
19
+ const INTENT_DELAY = 65
20
+
21
+ /**
22
+ * Fetch the split content of the page a link goes to, when a click on it would navigate in the
23
+ * app: not a fragment on this page, a `<Link reload>`, or a page in another locale.
24
+ *
25
+ * @param {HTMLAnchorElement} anchor
26
+ */
27
+ function prefetchFor(anchor) {
28
+ const link = inAppLink(anchor)
29
+ if (!link || link.href.startsWith('#') || anchor.hasAttribute('data-reload')) return
30
+ if (localeSwitchOf(link.routeHref, link.website)) return
31
+ prefetchContent(link.routeHref)
32
+ }
33
+
34
+ /**
35
+ * Fetch a link's page whenever a reader is about to follow it.
36
+ *
37
+ * @returns {() => void} stops watching
38
+ */
39
+ export function watchLinkIntent() {
40
+ if (globalThis.navigator?.connection?.saveData) return () => {}
41
+
42
+ let intent = null
43
+
44
+ const rest = (event) => {
45
+ const anchor = findAnchorElement(event.target)
46
+ if (!anchor || anchor === intent?.anchor) return
47
+ clearTimeout(intent?.timer)
48
+ intent = { anchor, timer: setTimeout(() => prefetchFor(anchor), INTENT_DELAY) }
49
+ }
50
+
51
+ const leave = (event) => {
52
+ if (!intent || intent.anchor.contains(event.relatedTarget)) return
53
+ clearTimeout(intent.timer)
54
+ intent = null
55
+ }
56
+
57
+ const press = (event) => {
58
+ const anchor = findAnchorElement(event.target)
59
+ if (anchor) prefetchFor(anchor)
60
+ }
61
+
62
+ const listeners = [
63
+ ['pointerover', rest],
64
+ ['focusin', rest],
65
+ ['pointerout', leave],
66
+ ['focusout', leave],
67
+ ['pointerdown', press],
68
+ ]
69
+ for (const [type, listener] of listeners) document.addEventListener(type, listener)
70
+
71
+ return () => {
72
+ for (const [type, listener] of listeners) document.removeEventListener(type, listener)
73
+ clearTimeout(intent?.timer)
74
+ }
75
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * In-app navigation: what the runtime does between a click and the next page.
3
+ *
4
+ * Every navigation the runtime makes goes through `navigateInApp`. That covers kit's `<Link>` and
5
+ * `useNavigate()` (the routing components `setup.js` registers) and a link in prose
6
+ * (`useLinkInterceptor`). It is one function so the two paths cannot decide differently again.
7
+ *
8
+ * ⭐ **The current page is held until the next one can be shown.** On a site whose content is
9
+ * split, a page not visited yet has no sections until its content file arrives, and
10
+ * `PageRenderer` renders nothing for its body until then. So the content is fetched first, while
11
+ * the current page stays on screen and usable. The router is told once the content has arrived,
12
+ * or after `CONTENT_PREFETCH_TIMEOUT`. That holds for every visitor: it depends on neither the
13
+ * View Transitions API nor a motion preference.
14
+ *
15
+ * ⭐ **The animation is a layer on the swap.** The swap runs inside
16
+ * `document.startViewTransition` when four things hold: view transitions are on
17
+ * (`website.viewTransitions`, on unless the foundation says `false`), the browser has the API,
18
+ * the visitor has no reduced-motion preference, and the next page is ready. The update callback
19
+ * resolves once the router has committed the new page (`nextPageCommit`). React Router commits a
20
+ * navigation inside `React.startTransition`, after `navigate()` has returned. A callback that
21
+ * resolved when `navigate()` did had the browser capture the old page as the new one.
22
+ *
23
+ * ⛔ **Until 2026-10-27 the two paths decided differently**, and neither held the page right:
24
+ * - `setup.js` read `foundationConfig.viewTransitions` as an opt-in, which
25
+ * `Website#viewTransitions` stopped being on 2026-04-11. So a `<Link>` neither animated nor
26
+ * waited for the content, and on a split site its body went blank until the content arrived
27
+ * (measured on two docs sites: 160–330 ms).
28
+ * - A prose link waited inside the transition, with the page frozen, and its callback resolved
29
+ * before the commit, so the crossfade went from the old page to itself.
30
+ * - A route carrying a query or a fragment found no page to wait for (`Website#getPage` takes a
31
+ * path).
32
+ * - A reduced-motion preference skipped the wait along with the animation.
33
+ */
34
+
35
+ /** How long a navigation waits for the next page's content before it goes anyway. */
36
+ const CONTENT_PREFETCH_TIMEOUT = 1000
37
+
38
+ /**
39
+ * How long a view transition waits for the router to commit. A navigation that commits nothing
40
+ * then cannot hold the frozen page until the browser gives up on it.
41
+ */
42
+ const COMMIT_TIMEOUT = 1000
43
+
44
+ /** Read once, when the runtime loads. */
45
+ export const prefersReducedMotion =
46
+ typeof window !== 'undefined'
47
+ && window.matchMedia('(prefers-reduced-motion: reduce)').matches
48
+
49
+ /**
50
+ * The page a navigation target names, found by its path: `Website#getPage` takes no query or
51
+ * fragment, and a docs link names a fragment as often as not.
52
+ *
53
+ * @param {string|Object} to - a router path, or `navigate`'s `{ pathname }`
54
+ * @returns {import('@uniweb/core').Page|null}
55
+ */
56
+ function pageAt(to) {
57
+ const website = globalThis.uniweb?.activeWebsite
58
+ const route = typeof to === 'string' ? to : to?.pathname
59
+ if (!route || !website) return null
60
+ return website.getPage(route.split(/[?#]/)[0]) || null
61
+ }
62
+
63
+ /** Whether `page` can be shown now: `PageRenderer`'s own test. */
64
+ function isReady(page) {
65
+ return !page || !page.hasContent() || page.isContentLoaded()
66
+ }
67
+
68
+ /** The wait for `page`'s split content, bounded by CONTENT_PREFETCH_TIMEOUT; undefined when there is none. */
69
+ function contentOf(page) {
70
+ if (isReady(page)) return
71
+ return Promise.race([
72
+ page.loadContent(),
73
+ new Promise((resolve) => setTimeout(resolve, CONTENT_PREFETCH_TIMEOUT)),
74
+ ])
75
+ }
76
+
77
+ /**
78
+ * Fetch the split content of the page `route` names, giving up after CONTENT_PREFETCH_TIMEOUT.
79
+ * Undefined when there is nothing to fetch: no such page, a page with no content, or one already
80
+ * loaded.
81
+ *
82
+ * @param {string} route - a router path; a query or fragment is ignored
83
+ * @returns {Promise|undefined}
84
+ */
85
+ export function prefetchContent(route) {
86
+ return contentOf(pageAt(route))
87
+ }
88
+
89
+ // The navigation the next swap belongs to. A later navigation supersedes one still waiting for
90
+ // its content, so a slow page reached by an earlier click cannot replace the page clicked last.
91
+ let latest = 0
92
+
93
+ /**
94
+ * Navigate in the app. The current page is held until the next one's content is here, then the
95
+ * pages are swapped, animated when view transitions apply.
96
+ *
97
+ * @param {Function} navigate - React Router's `navigate`
98
+ * @param {string|Object|number} to - where to, as `navigate` takes it
99
+ * @param {Object} [options] - `navigate`'s options
100
+ */
101
+ export function navigateInApp(navigate, to, options) {
102
+ const ticket = ++latest
103
+ const page = pageAt(to)
104
+
105
+ const swap = () => {
106
+ if (ticket !== latest) return
107
+ const website = globalThis.uniweb?.activeWebsite
108
+ // A page still not here after the wait goes without the animation: a crossfade to an empty
109
+ // body would only draw out the wait.
110
+ const animate = isReady(page) && website?.viewTransitions
111
+ && typeof document !== 'undefined' && document.startViewTransition && !prefersReducedMotion
112
+ if (!animate) {
113
+ navigate(to, options)
114
+ return
115
+ }
116
+ const transition = document.startViewTransition(() => {
117
+ // Asked before navigating: a router that commits synchronously settles it from inside `navigate`.
118
+ const committed = nextPageCommit()
119
+ navigate(to, options)
120
+ return committed
121
+ })
122
+ // A transition the browser skips, for a duplicate `view-transition-name` or a newer
123
+ // transition, still runs its callback, so the page is swapped all the same. The browser
124
+ // reports the reason itself.
125
+ transition.ready.catch(() => {})
126
+ }
127
+
128
+ const pending = contentOf(page)
129
+ // A fetch that fails still navigates: the page then shows what its content answered.
130
+ if (pending) pending.then(swap, swap)
131
+ else swap()
132
+ }
133
+
134
+ const commitWaiters = new Set()
135
+
136
+ /**
137
+ * A promise settled when `PageRenderer` next commits a page, or after COMMIT_TIMEOUT.
138
+ *
139
+ * @returns {Promise<void>}
140
+ */
141
+ function nextPageCommit() {
142
+ return new Promise((resolve) => {
143
+ const settle = () => {
144
+ clearTimeout(timer)
145
+ commitWaiters.delete(settle)
146
+ resolve()
147
+ }
148
+ const timer = setTimeout(settle, COMMIT_TIMEOUT)
149
+ commitWaiters.add(settle)
150
+ })
151
+ }
152
+
153
+ /**
154
+ * Called by `PageRenderer` from a layout effect, once the DOM holds the page the router
155
+ * committed and before the browser paints it. That is the moment a view transition may capture
156
+ * it. It has no SSR twin, since the prerender makes no navigation.
157
+ */
158
+ export function pageCommitted() {
159
+ for (const settle of [...commitWaiters]) settle()
160
+ }
package/src/setup.js CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  import { ChildBlocks } from './components/PageRenderer.jsx'
32
32
  import Background from './components/Background.jsx'
33
33
  import { wireFoundationCapabilities, hydrateDataStore, ensureThemeCss } from './wire-foundation.js'
34
+ import { navigateInApp } from './navigation.js'
34
35
 
35
36
  // Re-export so existing consumers importing hydrateDataStore from
36
37
  // setup.js keep working. The implementation moved to wire-foundation.js
@@ -38,56 +39,18 @@ import { wireFoundationCapabilities, hydrateDataStore, ensureThemeCss } from './
38
39
  // without dragging react-dom/server into the SPA bundle.
39
40
  export { hydrateDataStore }
40
41
 
41
- // ─── View Transition Wrappers ───────────────────────────────────────────────
42
- //
43
- // When the foundation enables viewTransitions, navigation is wrapped in
44
- // document.startViewTransition() to animate page changes. Split content
45
- // is prefetched inside the transition callback so the user never sees
46
- // a blank or loading state.
42
+ // `@uniweb/runtime/setup` has exported these since view transitions landed. They live with the
43
+ // navigation that uses them now.
44
+ export { prefetchContent, prefersReducedMotion } from './navigation.js'
47
45
 
48
- /**
49
- * Prefetch split page content with a timeout.
50
- * Resolves when content is loaded or the timeout expires — whichever
51
- * comes first. This keeps the old-page screenshot from freezing too
52
- * long on slow connections. If the timeout wins, navigation proceeds
53
- * and the PageRenderer loading gate handles the rest.
54
- */
55
- const CONTENT_PREFETCH_TIMEOUT = 1000
56
- export const prefersReducedMotion =
57
- typeof window !== 'undefined'
58
- && window.matchMedia('(prefers-reduced-motion: reduce)').matches
59
-
60
- export function prefetchContent(route) {
61
- const website = globalThis.uniweb?.activeWebsite
62
- if (!route || !website) return
63
-
64
- const targetPage = website.getPage(route)
65
- if (!targetPage?.hasContent?.() || targetPage.isContentLoaded?.()) return
66
-
67
- return Promise.race([
68
- targetPage.loadContent(),
69
- new Promise((resolve) => setTimeout(resolve, CONTENT_PREFETCH_TIMEOUT)),
70
- ])
71
- }
72
-
73
- /**
74
- * Prefetch split content and navigate inside a view transition.
75
- * Falls back to plain navigation when transitions are not available.
76
- */
77
- function navigateWithTransition(navigate, to, options) {
78
- const vt = globalThis.uniweb?.foundationConfig?.viewTransitions
79
- if (vt && document.startViewTransition && !prefersReducedMotion) {
80
- document.startViewTransition(async () => {
81
- const route = typeof to === 'string' ? to : to?.pathname
82
- await prefetchContent(route)
83
- navigate(to, options)
84
- })
85
- } else {
86
- navigate(to, options)
87
- }
88
- }
46
+ // ─── In-app navigation ──────────────────────────────────────────────────────
47
+ //
48
+ // Kit's <Link> and useRouting().useNavigate() reach the router through these, so every
49
+ // navigation they make goes through `navigateInApp` (`navigation.js`): the current page is
50
+ // held until the next one's content is here, and the swap is animated when view transitions
51
+ // apply.
89
52
 
90
- const ViewTransitionLink = React.forwardRef(function ViewTransitionLink(
53
+ const InAppLink = React.forwardRef(function InAppLink(
91
54
  { onClick, replace, state, preventScrollReset, ...props },
92
55
  ref,
93
56
  ) {
@@ -98,12 +61,13 @@ const ViewTransitionLink = React.forwardRef(function ViewTransitionLink(
98
61
  if (e.defaultPrevented) return
99
62
  if (e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) return
100
63
  if (e.button !== 0) return
101
-
102
- const vt = globalThis.uniweb?.foundationConfig?.viewTransitions
103
- if (!vt || !document.startViewTransition || prefersReducedMotion) return
64
+ // Another tab or frame is the browser's to open. Kit passes a link's `target` on.
65
+ if (props.target && props.target !== '_self') return
104
66
 
105
67
  e.preventDefault()
106
- navigateWithTransition(navigate, props.to, { replace, state, preventScrollReset })
68
+ // A link to the page already shown replaces its history entry, as React Router's own does.
69
+ const here = e.currentTarget.href === window.location.href
70
+ navigateInApp(navigate, props.to, { replace: replace ?? here, state, preventScrollReset })
107
71
  }
108
72
 
109
73
  return React.createElement(RouterLink, {
@@ -116,13 +80,10 @@ const ViewTransitionLink = React.forwardRef(function ViewTransitionLink(
116
80
  })
117
81
  })
118
82
 
119
- /**
120
- * View-transition-aware useNavigate hook.
121
- * The returned function wraps navigation in startViewTransition() when enabled.
122
- */
83
+ /** React Router's `useNavigate`, its navigations made through `navigateInApp`. */
123
84
  function useNavigate() {
124
85
  const navigate = useRouterNavigate()
125
- return (to, options) => navigateWithTransition(navigate, to, options)
86
+ return (to, options) => navigateInApp(navigate, to, options)
126
87
  }
127
88
 
128
89
  /**
@@ -130,7 +91,7 @@ function useNavigate() {
130
91
  * its own when it wraps DynamicApp in a MemoryRouter.
131
92
  */
132
93
  const DEFAULT_ROUTING_COMPONENTS = {
133
- Link: ViewTransitionLink,
94
+ Link: InAppLink,
134
95
  useNavigate,
135
96
  useParams,
136
97
  useLocation,