one 1.2.79 → 1.2.81

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.
Files changed (178) hide show
  1. package/dist/cjs/Root.cjs +1 -1
  2. package/dist/cjs/Root.js +1 -1
  3. package/dist/cjs/Root.js.map +1 -1
  4. package/dist/cjs/Root.native.js +1 -1
  5. package/dist/cjs/Root.native.js.map +1 -1
  6. package/dist/cjs/drawer.cjs +27 -0
  7. package/dist/cjs/drawer.js +22 -0
  8. package/dist/cjs/drawer.js.map +6 -0
  9. package/dist/cjs/drawer.native.js +30 -0
  10. package/dist/cjs/drawer.native.js.map +1 -0
  11. package/dist/cjs/hooks.cjs +25 -2
  12. package/dist/cjs/hooks.js +23 -2
  13. package/dist/cjs/hooks.js.map +1 -1
  14. package/dist/cjs/hooks.native.js +147 -14
  15. package/dist/cjs/hooks.native.js.map +1 -1
  16. package/dist/cjs/hooks.test.cjs +73 -0
  17. package/dist/cjs/hooks.test.js +98 -0
  18. package/dist/cjs/hooks.test.js.map +6 -0
  19. package/dist/cjs/hooks.test.native.js +194 -0
  20. package/dist/cjs/hooks.test.native.js.map +1 -0
  21. package/dist/cjs/index.cjs +1 -0
  22. package/dist/cjs/index.js +1 -0
  23. package/dist/cjs/index.js.map +1 -1
  24. package/dist/cjs/index.native.js +1 -0
  25. package/dist/cjs/index.native.js.map +1 -1
  26. package/dist/cjs/link/prefetchIntent.cjs +122 -0
  27. package/dist/cjs/link/prefetchIntent.js +85 -0
  28. package/dist/cjs/link/prefetchIntent.js.map +6 -0
  29. package/dist/cjs/link/prefetchIntent.native.js +155 -0
  30. package/dist/cjs/link/prefetchIntent.native.js.map +1 -0
  31. package/dist/cjs/link/prefetchIntent.test.cjs +217 -0
  32. package/dist/cjs/link/prefetchIntent.test.js +149 -0
  33. package/dist/cjs/link/prefetchIntent.test.js.map +6 -0
  34. package/dist/cjs/link/prefetchIntent.test.native.js +239 -0
  35. package/dist/cjs/link/prefetchIntent.test.native.js.map +1 -0
  36. package/dist/cjs/link/prefetchViewport.cjs +67 -0
  37. package/dist/cjs/link/prefetchViewport.js +55 -0
  38. package/dist/cjs/link/prefetchViewport.js.map +6 -0
  39. package/dist/cjs/link/prefetchViewport.native.js +83 -0
  40. package/dist/cjs/link/prefetchViewport.native.js.map +1 -0
  41. package/dist/cjs/link/prefetchViewport.test.cjs +57 -0
  42. package/dist/cjs/link/prefetchViewport.test.js +59 -0
  43. package/dist/cjs/link/prefetchViewport.test.js.map +6 -0
  44. package/dist/cjs/link/prefetchViewport.test.native.js +85 -0
  45. package/dist/cjs/link/prefetchViewport.test.native.js.map +1 -0
  46. package/dist/cjs/router/findRouteNode.cjs +26 -0
  47. package/dist/cjs/router/findRouteNode.js +28 -0
  48. package/dist/cjs/router/findRouteNode.js.map +1 -1
  49. package/dist/cjs/router/findRouteNode.native.js +31 -0
  50. package/dist/cjs/router/findRouteNode.native.js.map +1 -1
  51. package/dist/cjs/router/router.cjs +11 -13
  52. package/dist/cjs/router/router.js +8 -10
  53. package/dist/cjs/router/router.js.map +2 -2
  54. package/dist/cjs/router/router.native.js +38 -122
  55. package/dist/cjs/router/router.native.js.map +1 -1
  56. package/dist/cjs/views/PreloadLinks.cjs +102 -18
  57. package/dist/cjs/views/PreloadLinks.js +95 -19
  58. package/dist/cjs/views/PreloadLinks.js.map +1 -1
  59. package/dist/cjs/vite/one.cjs +3 -0
  60. package/dist/cjs/vite/one.js +3 -0
  61. package/dist/cjs/vite/one.js.map +1 -1
  62. package/dist/cjs/vite/one.native.js +4 -0
  63. package/dist/cjs/vite/one.native.js.map +1 -1
  64. package/dist/esm/Root.js +1 -1
  65. package/dist/esm/Root.js.map +1 -1
  66. package/dist/esm/Root.mjs +1 -1
  67. package/dist/esm/Root.mjs.map +1 -1
  68. package/dist/esm/Root.native.js +1 -1
  69. package/dist/esm/Root.native.js.map +1 -1
  70. package/dist/esm/drawer.js +6 -0
  71. package/dist/esm/drawer.js.map +6 -0
  72. package/dist/esm/drawer.mjs +3 -0
  73. package/dist/esm/drawer.mjs.map +1 -0
  74. package/dist/esm/drawer.native.js +3 -0
  75. package/dist/esm/drawer.native.js.map +1 -0
  76. package/dist/esm/hooks.js +23 -2
  77. package/dist/esm/hooks.js.map +1 -1
  78. package/dist/esm/hooks.mjs +25 -3
  79. package/dist/esm/hooks.mjs.map +1 -1
  80. package/dist/esm/hooks.native.js +147 -15
  81. package/dist/esm/hooks.native.js.map +1 -1
  82. package/dist/esm/hooks.test.js +98 -0
  83. package/dist/esm/hooks.test.js.map +6 -0
  84. package/dist/esm/hooks.test.mjs +74 -0
  85. package/dist/esm/hooks.test.mjs.map +1 -0
  86. package/dist/esm/hooks.test.native.js +192 -0
  87. package/dist/esm/hooks.test.native.js.map +1 -0
  88. package/dist/esm/index.js +2 -0
  89. package/dist/esm/index.js.map +1 -1
  90. package/dist/esm/index.mjs +2 -2
  91. package/dist/esm/index.mjs.map +1 -1
  92. package/dist/esm/index.native.js +2 -2
  93. package/dist/esm/index.native.js.map +1 -1
  94. package/dist/esm/link/prefetchIntent.js +69 -0
  95. package/dist/esm/link/prefetchIntent.js.map +6 -0
  96. package/dist/esm/link/prefetchIntent.mjs +97 -0
  97. package/dist/esm/link/prefetchIntent.mjs.map +1 -0
  98. package/dist/esm/link/prefetchIntent.native.js +127 -0
  99. package/dist/esm/link/prefetchIntent.native.js.map +1 -0
  100. package/dist/esm/link/prefetchIntent.test.js +150 -0
  101. package/dist/esm/link/prefetchIntent.test.js.map +6 -0
  102. package/dist/esm/link/prefetchIntent.test.mjs +218 -0
  103. package/dist/esm/link/prefetchIntent.test.mjs.map +1 -0
  104. package/dist/esm/link/prefetchIntent.test.native.js +237 -0
  105. package/dist/esm/link/prefetchIntent.test.native.js.map +1 -0
  106. package/dist/esm/link/prefetchViewport.js +39 -0
  107. package/dist/esm/link/prefetchViewport.js.map +6 -0
  108. package/dist/esm/link/prefetchViewport.mjs +42 -0
  109. package/dist/esm/link/prefetchViewport.mjs.map +1 -0
  110. package/dist/esm/link/prefetchViewport.native.js +55 -0
  111. package/dist/esm/link/prefetchViewport.native.js.map +1 -0
  112. package/dist/esm/link/prefetchViewport.test.js +60 -0
  113. package/dist/esm/link/prefetchViewport.test.js.map +6 -0
  114. package/dist/esm/link/prefetchViewport.test.mjs +58 -0
  115. package/dist/esm/link/prefetchViewport.test.mjs.map +1 -0
  116. package/dist/esm/link/prefetchViewport.test.native.js +83 -0
  117. package/dist/esm/link/prefetchViewport.test.native.js.map +1 -0
  118. package/dist/esm/router/findRouteNode.js +28 -0
  119. package/dist/esm/router/findRouteNode.js.map +1 -1
  120. package/dist/esm/router/findRouteNode.mjs +26 -1
  121. package/dist/esm/router/findRouteNode.mjs.map +1 -1
  122. package/dist/esm/router/findRouteNode.native.js +31 -1
  123. package/dist/esm/router/findRouteNode.native.js.map +1 -1
  124. package/dist/esm/router/router.js +10 -11
  125. package/dist/esm/router/router.js.map +2 -2
  126. package/dist/esm/router/router.mjs +12 -14
  127. package/dist/esm/router/router.mjs.map +1 -1
  128. package/dist/esm/router/router.native.js +39 -123
  129. package/dist/esm/router/router.native.js.map +1 -1
  130. package/dist/esm/views/PreloadLinks.js +86 -17
  131. package/dist/esm/views/PreloadLinks.js.map +1 -1
  132. package/dist/esm/views/PreloadLinks.mjs +87 -14
  133. package/dist/esm/views/PreloadLinks.mjs.map +1 -1
  134. package/dist/esm/vite/one.js +3 -0
  135. package/dist/esm/vite/one.js.map +1 -1
  136. package/dist/esm/vite/one.mjs +3 -0
  137. package/dist/esm/vite/one.mjs.map +1 -1
  138. package/dist/esm/vite/one.native.js +4 -0
  139. package/dist/esm/vite/one.native.js.map +1 -1
  140. package/package.json +33 -12
  141. package/src/Root.tsx +1 -1
  142. package/src/drawer.ts +1 -0
  143. package/src/hooks.test.ts +157 -0
  144. package/src/hooks.tsx +79 -23
  145. package/src/index.ts +1 -0
  146. package/src/link/prefetchIntent.test.ts +416 -0
  147. package/src/link/prefetchIntent.ts +174 -0
  148. package/src/link/prefetchViewport.test.ts +120 -0
  149. package/src/link/prefetchViewport.ts +62 -0
  150. package/src/router/findRouteNode.ts +67 -0
  151. package/src/router/router.ts +68 -41
  152. package/src/views/PreloadLinks.tsx +156 -20
  153. package/src/vite/one.ts +4 -0
  154. package/src/vite/types.ts +12 -0
  155. package/types/drawer.d.ts +2 -0
  156. package/types/drawer.d.ts.map +1 -0
  157. package/types/hooks.d.ts +22 -0
  158. package/types/hooks.d.ts.map +1 -1
  159. package/types/hooks.test.d.ts +2 -0
  160. package/types/hooks.test.d.ts.map +1 -0
  161. package/types/index.d.ts +1 -1
  162. package/types/index.d.ts.map +1 -1
  163. package/types/link/prefetchIntent.d.ts +43 -0
  164. package/types/link/prefetchIntent.d.ts.map +1 -0
  165. package/types/link/prefetchIntent.test.d.ts +2 -0
  166. package/types/link/prefetchIntent.test.d.ts.map +1 -0
  167. package/types/link/prefetchViewport.d.ts +16 -0
  168. package/types/link/prefetchViewport.d.ts.map +1 -0
  169. package/types/link/prefetchViewport.test.d.ts +2 -0
  170. package/types/link/prefetchViewport.test.d.ts.map +1 -0
  171. package/types/router/findRouteNode.d.ts +11 -0
  172. package/types/router/findRouteNode.d.ts.map +1 -1
  173. package/types/router/router.d.ts.map +1 -1
  174. package/types/views/PreloadLinks.d.ts +9 -0
  175. package/types/views/PreloadLinks.d.ts.map +1 -1
  176. package/types/vite/one.d.ts.map +1 -1
  177. package/types/vite/types.d.ts +11 -0
  178. package/types/vite/types.d.ts.map +1 -1
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Intent-based link prefetching using mouse trajectory prediction.
3
+ *
4
+ * Uses ray-casting to detect when the user is moving toward a link,
5
+ * scoring by distance + perpendicular offset. Winner-takes-all prevents
6
+ * over-fetching when multiple links are in the path.
7
+ *
8
+ * Key features:
9
+ * - Batched IntersectionObserver for efficient rect measurement (300ms intervals)
10
+ * - Velocity smoothing to filter jitter
11
+ * - Prefetched links removed from future checks
12
+ * - Debounce between consecutive prefetches
13
+ */
14
+
15
+ export type PrefetchIntentOptions = {
16
+ onPrefetch: (href: string) => void
17
+ /** Max distance to consider (default: 600px) */
18
+ maxReach?: number
19
+ /** Penalty multiplier for off-axis aim (default: 4) */
20
+ perpWeight?: number
21
+ /** Min mouse speed to trigger (default: 6px/frame) */
22
+ minSpeed?: number
23
+ }
24
+
25
+ type RectEntry = { r: DOMRectReadOnly; h: string }
26
+
27
+ export function createPrefetchIntent(options: PrefetchIntentOptions) {
28
+ const { onPrefetch, maxReach = 500, perpWeight = 5, minSpeed = 8 } = options
29
+
30
+ const done = new Set<string>()
31
+ let rects: RectEntry[] = []
32
+ let px = 0,
33
+ py = 0,
34
+ vx = 0,
35
+ vy = 0
36
+ let moveCount = 0
37
+ let lastPrefetchMove = 0
38
+
39
+ function setRects(newRects: RectEntry[]) {
40
+ rects = newRects.filter((r) => !done.has(r.h))
41
+ }
42
+
43
+ function move(x: number, y: number, dx: number, dy: number) {
44
+ moveCount++
45
+ // less smoothing on first few moves to build velocity faster
46
+ const smooth = moveCount < 3 ? 0.3 : 0.6
47
+ vx = vx * smooth + dx * (1 - smooth)
48
+ vy = vy * smooth + dy * (1 - smooth)
49
+ px = x
50
+ py = y
51
+
52
+ const speed = Math.sqrt(vx * vx + vy * vy)
53
+ if (speed < minSpeed) return
54
+
55
+ // normalize velocity to unit vector
56
+ const ux = vx / speed
57
+ const uy = vy / speed
58
+
59
+ let best = Infinity
60
+ let href = ''
61
+
62
+ for (const { r, h } of rects) {
63
+ // vector from cursor to link center
64
+ const cx = (r.left + r.right) / 2 - px
65
+ const cy = (r.top + r.bottom) / 2 - py
66
+ const dist = Math.sqrt(cx * cx + cy * cy)
67
+
68
+ // project center onto velocity ray: how far along the ray?
69
+ const along = cx * ux + cy * uy
70
+ if (along < 0) continue // behind us
71
+
72
+ // perpendicular distance from ray to center
73
+ const perpX = cx - along * ux
74
+ const perpY = cy - along * uy
75
+ const perp = Math.sqrt(perpX * perpX + perpY * perpY)
76
+
77
+ // miss if perpendicular distance > half the link size + margin
78
+ // scale margin by distance - farther = need tighter aim
79
+ const baseRadius = Math.max(r.width, r.height) / 2 + 30
80
+ const distanceFactor = Math.max(0.2, 1 - dist / 1000)
81
+ const radius = baseRadius * distanceFactor + 20
82
+ if (perp > radius) continue
83
+
84
+ // score: balance distance and aim quality
85
+ // absolute perp matters more than relative - being 70px off at any distance is worse than 20px off
86
+ // score = distance + perp * perpWeight (lower is better)
87
+ const score = along + perp * perpWeight
88
+ if (score < best) {
89
+ best = score
90
+ href = h
91
+ }
92
+ }
93
+
94
+ // winner takes all with max reach
95
+ // debounce: skip if we just prefetched on previous move
96
+ if (href && best < maxReach && moveCount - lastPrefetchMove > 1) {
97
+ done.add(href)
98
+ rects = rects.filter((r) => r.h !== href)
99
+ lastPrefetchMove = moveCount
100
+ onPrefetch(href)
101
+ }
102
+ }
103
+
104
+ // for integration with DOM
105
+ const nodes = new Map<HTMLElement, string>()
106
+
107
+ function observe(el: HTMLElement, href: string) {
108
+ nodes.set(el, href)
109
+ return () => {
110
+ nodes.delete(el)
111
+ done.delete(href)
112
+ }
113
+ }
114
+
115
+ return { setRects, move, observe, nodes, done }
116
+ }
117
+
118
+ // singleton for actual DOM usage
119
+ let instance: ReturnType<typeof createPrefetchIntent> | null = null
120
+ let started = false
121
+
122
+ export function startPrefetchIntent(onPrefetch: (href: string) => void) {
123
+ if (started) return instance!
124
+ started = true
125
+
126
+ instance = createPrefetchIntent({ onPrefetch })
127
+
128
+ let frame = 0
129
+ let px = 0,
130
+ py = 0
131
+
132
+ // batch measure all nodes using single IntersectionObserver
133
+ function measure() {
134
+ if (!instance!.nodes.size) return setTimeout(measure, 300)
135
+ const io = new IntersectionObserver((entries) => {
136
+ io.disconnect()
137
+ instance!.setRects(
138
+ entries
139
+ .filter((e) => e.isIntersecting)
140
+ .map((e) => ({
141
+ r: e.boundingClientRect,
142
+ h: instance!.nodes.get(e.target as HTMLElement)!,
143
+ }))
144
+ .filter((x) => x.h)
145
+ )
146
+ setTimeout(measure, 300)
147
+ })
148
+ instance!.nodes.forEach((_, el) => io.observe(el))
149
+ }
150
+ measure()
151
+
152
+ document.addEventListener(
153
+ 'mousemove',
154
+ (e) => {
155
+ // throttle to every 4th frame (~66ms at 60fps)
156
+ if (++frame % 4) return
157
+
158
+ const dx = e.clientX - px
159
+ const dy = e.clientY - py
160
+ px = e.clientX
161
+ py = e.clientY
162
+
163
+ instance!.move(px, py, dx, dy)
164
+ },
165
+ { passive: true }
166
+ )
167
+
168
+ return instance
169
+ }
170
+
171
+ export function observePrefetchIntent(el: HTMLElement, href: string) {
172
+ if (!instance) return () => {}
173
+ return instance.observe(el, href)
174
+ }
@@ -0,0 +1,120 @@
1
+ import { describe, expect, it, beforeEach, vi } from 'vitest'
2
+ import { createPrefetchViewport } from './prefetchViewport'
3
+
4
+ // mock IntersectionObserver
5
+ let mockIOCallback: IntersectionObserverCallback
6
+ const mockObserved = new Set<Element>()
7
+
8
+ vi.stubGlobal(
9
+ 'IntersectionObserver',
10
+ class {
11
+ constructor(callback: IntersectionObserverCallback) {
12
+ mockIOCallback = callback
13
+ }
14
+ observe(el: Element) {
15
+ mockObserved.add(el)
16
+ }
17
+ unobserve(el: Element) {
18
+ mockObserved.delete(el)
19
+ }
20
+ disconnect() {
21
+ mockObserved.clear()
22
+ }
23
+ }
24
+ )
25
+
26
+ function simulateIntersect(el: Element, isIntersecting: boolean) {
27
+ mockIOCallback(
28
+ [{ target: el, isIntersecting } as IntersectionObserverEntry],
29
+ {} as IntersectionObserver
30
+ )
31
+ }
32
+
33
+ describe('prefetchViewport', () => {
34
+ let prefetched: string[]
35
+ let vp: ReturnType<typeof createPrefetchViewport>
36
+
37
+ beforeEach(() => {
38
+ prefetched = []
39
+ mockObserved.clear()
40
+ vp = createPrefetchViewport()
41
+ vp.start((href) => prefetched.push(href))
42
+ })
43
+
44
+ it('prefetches when link enters viewport', () => {
45
+ const el = {} as HTMLElement
46
+ vp.observe(el, '/about')
47
+
48
+ simulateIntersect(el, true)
49
+ expect(prefetched).toEqual(['/about'])
50
+ })
51
+
52
+ it('does not prefetch when link exits viewport', () => {
53
+ const el = {} as HTMLElement
54
+ vp.observe(el, '/about')
55
+
56
+ simulateIntersect(el, false)
57
+ expect(prefetched).toEqual([])
58
+ })
59
+
60
+ it('only prefetches each href once', () => {
61
+ const el = {} as HTMLElement
62
+ vp.observe(el, '/about')
63
+
64
+ simulateIntersect(el, true)
65
+ simulateIntersect(el, false)
66
+ simulateIntersect(el, true)
67
+
68
+ expect(prefetched).toEqual(['/about'])
69
+ })
70
+
71
+ it('cleanup re-enables prefetch for href', () => {
72
+ const el = {} as HTMLElement
73
+ const cleanup = vp.observe(el, '/about')
74
+
75
+ simulateIntersect(el, true)
76
+ expect(prefetched).toEqual(['/about'])
77
+
78
+ cleanup()
79
+ prefetched.length = 0
80
+
81
+ // re-observe same href after cleanup
82
+ vp.observe(el, '/about')
83
+ simulateIntersect(el, true)
84
+ expect(prefetched).toEqual(['/about'])
85
+ })
86
+
87
+ describe('memory and performance', () => {
88
+ it('does not leak elements after cleanup', () => {
89
+ const elements: HTMLElement[] = []
90
+ const cleanups: (() => void)[] = []
91
+
92
+ // add 100 elements
93
+ for (let i = 0; i < 100; i++) {
94
+ const el = {} as HTMLElement
95
+ elements.push(el)
96
+ cleanups.push(vp.observe(el, `/page-${i}`))
97
+ }
98
+
99
+ expect(vp.nodes.size).toBe(100)
100
+
101
+ // cleanup all
102
+ cleanups.forEach((c) => c())
103
+
104
+ expect(vp.nodes.size).toBe(0)
105
+ expect(vp.done.size).toBe(0)
106
+ })
107
+
108
+ it('handles rapid observe/unobserve cycles', () => {
109
+ const el = {} as HTMLElement
110
+
111
+ for (let i = 0; i < 100; i++) {
112
+ const cleanup = vp.observe(el, '/test')
113
+ cleanup()
114
+ }
115
+
116
+ // should not leak
117
+ expect(vp.nodes.size).toBe(0)
118
+ })
119
+ })
120
+ })
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Viewport-based link prefetching using IntersectionObserver.
3
+ *
4
+ * Prefetches links when they enter the viewport with 100px margin.
5
+ * Lighter weight than intent-based, good for content-heavy pages.
6
+ */
7
+
8
+ export type ViewportPrefetcher = ReturnType<typeof createPrefetchViewport>
9
+
10
+ export function createPrefetchViewport() {
11
+ const done = new Set<string>()
12
+ const nodes = new Map<HTMLElement, string>()
13
+ let io: IntersectionObserver | null = null
14
+ let onPrefetch: ((href: string) => void) | null = null
15
+
16
+ function getObserver() {
17
+ if (io) return io
18
+ io = new IntersectionObserver(
19
+ (entries) => {
20
+ for (const entry of entries) {
21
+ if (!entry.isIntersecting) continue
22
+ const href = nodes.get(entry.target as HTMLElement)
23
+ if (href && !done.has(href)) {
24
+ done.add(href)
25
+ onPrefetch?.(href)
26
+ }
27
+ }
28
+ },
29
+ { threshold: 0.5 } // fires when mostly visible
30
+ )
31
+ return io
32
+ }
33
+
34
+ function start(prefetch: (href: string) => void) {
35
+ onPrefetch = prefetch
36
+ }
37
+
38
+ function observe(el: HTMLElement, href: string) {
39
+ nodes.set(el, href)
40
+ getObserver().observe(el)
41
+ return () => {
42
+ nodes.delete(el)
43
+ io?.unobserve(el)
44
+ done.delete(href)
45
+ }
46
+ }
47
+
48
+ return { start, observe, done, nodes }
49
+ }
50
+
51
+ // singleton for actual DOM usage
52
+ let instance: ViewportPrefetcher | null = null
53
+
54
+ export function startPrefetchViewport(prefetch: (href: string) => void) {
55
+ if (!instance) instance = createPrefetchViewport()
56
+ instance.start(prefetch)
57
+ }
58
+
59
+ export function observePrefetchViewport(el: HTMLElement, href: string) {
60
+ if (!instance) instance = createPrefetchViewport()
61
+ return instance.observe(el, href)
62
+ }
@@ -125,3 +125,70 @@ export function extractPathnameFromHref(href: string): string {
125
125
 
126
126
  return href.slice(0, endIndex)
127
127
  }
128
+
129
+ /**
130
+ * Find all route nodes from root to the current page based on navigation state.
131
+ * Returns an array of RouteNodes in order from root layout to the page.
132
+ * This is used on native to build the full matches array including layouts.
133
+ */
134
+ export function findAllRouteNodesFromState(
135
+ state: { routes: Array<{ name: string; state?: any }> } | undefined,
136
+ rootNode: RouteNode | null
137
+ ): RouteNode[] {
138
+ if (!state || !rootNode) {
139
+ return []
140
+ }
141
+
142
+ const nodes: RouteNode[] = []
143
+
144
+ function collectNodes(
145
+ currentState: { routes: Array<{ name: string; state?: any; params?: Record<string, any> }> } | undefined,
146
+ parentNode: RouteNode | null
147
+ ) {
148
+ if (!currentState || !parentNode) {
149
+ return
150
+ }
151
+
152
+ // get the current route from state (the active one based on index)
153
+ const currentRoute = currentState.routes[currentState.routes.length - 1]
154
+ if (!currentRoute) {
155
+ return
156
+ }
157
+
158
+ // find the matching child node
159
+ const matchingNode = findNodeByRouteName(parentNode, currentRoute.name)
160
+ if (!matchingNode) {
161
+ if (process.env.NODE_ENV === 'development') {
162
+ console.log('[one] findAllRouteNodesFromState: could not find node for', currentRoute.name, 'in', parentNode.route)
163
+ }
164
+ return
165
+ }
166
+
167
+ // add this node to the list
168
+ nodes.push(matchingNode)
169
+
170
+ // if there's a nested state, continue recursively
171
+ if (currentRoute.state && currentRoute.state.routes) {
172
+ collectNodes(currentRoute.state, matchingNode)
173
+ } else if (currentRoute.params?.screen) {
174
+ // react navigation uses params.screen to specify child route when state isn't created yet
175
+ // this happens on initial render before the nested navigator mounts
176
+ const childRouteName = currentRoute.params.screen as string
177
+ const childNode = matchingNode.children.find((c) => c.route === childRouteName)
178
+ if (childNode) {
179
+ nodes.push(childNode)
180
+ // if there are nested params, continue recursively
181
+ if (currentRoute.params.params && (currentRoute.params.params as any).screen) {
182
+ collectNodes(
183
+ { routes: [{ name: childRouteName, params: currentRoute.params.params }] },
184
+ childNode
185
+ )
186
+ }
187
+ }
188
+ }
189
+ }
190
+
191
+ collectNodes(state, rootNode)
192
+ return nodes
193
+ }
194
+
@@ -37,6 +37,7 @@ import {
37
37
  extractPathnameFromHref,
38
38
  extractSearchFromHref,
39
39
  findRouteNodeFromState,
40
+ findAllRouteNodesFromState,
40
41
  } from './findRouteNode'
41
42
  import type { UrlObject } from './getNormalizedStatePath'
42
43
  import { getRouteInfo } from './getRouteInfo'
@@ -399,6 +400,16 @@ export function updateState(state: OneRouter.ResultState, nextStateParam = state
399
400
  console.info(`[one] 🧭 ${from} → ${to}`, params ? { params } : '')
400
401
  }
401
402
  routeInfo = nextRouteInfo
403
+
404
+ // On native, update client matches when route changes
405
+ // This enables useMatches to work for initial route and navigation
406
+ // Loader data will be undefined initially (fetched by useLoader)
407
+ if (process.env.TAMAGUI_TARGET === 'native') {
408
+ const params = extractParamsFromState(state)
409
+ const newMatches = buildNativeMatches(state, nextRouteInfo.pathname, params)
410
+ currentMatches = newMatches
411
+ setClientMatches(newMatches)
412
+ }
402
413
  }
403
414
 
404
415
  // Expose devtools API in development
@@ -717,45 +728,43 @@ export function getPreloadHistory(): PreloadEntry[] {
717
728
  }
718
729
 
719
730
  export function preloadRoute(href: string, injectCSS = false): Promise<any> | undefined {
720
- if (process.env.TAMAGUI_TARGET === 'native') {
721
- return
722
- }
731
+ if (process.env.TAMAGUI_TARGET !== 'native') {
732
+ // in dev mode, use a simpler preload that just fetches the loader directly
733
+ // this avoids issues with production-only preload paths while still ensuring
734
+ // loader data is available before navigation completes
735
+ if (process.env.NODE_ENV === 'development') {
736
+ // normalize the path to match what useLoader uses for cache keys
737
+ const normalizedHref = normalizeLoaderPath(href)
738
+ if (!preloadingLoader[normalizedHref]) {
739
+ preloadingLoader[normalizedHref] = doPreloadDev(href).then((data) => {
740
+ preloadedLoaderData[normalizedHref] = data
741
+ return data
742
+ })
743
+ }
744
+ return preloadingLoader[normalizedHref]
745
+ }
723
746
 
724
- // in dev mode, use a simpler preload that just fetches the loader directly
725
- // this avoids issues with production-only preload paths while still ensuring
726
- // loader data is available before navigation completes
727
- if (process.env.NODE_ENV === 'development') {
728
- // normalize the path to match what useLoader uses for cache keys
729
- const normalizedHref = normalizeLoaderPath(href)
730
- if (!preloadingLoader[normalizedHref]) {
731
- preloadingLoader[normalizedHref] = doPreloadDev(href).then((data) => {
732
- preloadedLoaderData[normalizedHref] = data
747
+ if (!preloadingLoader[href]) {
748
+ preloadingLoader[href] = doPreload(href).then((data) => {
749
+ // Store the resolved data for synchronous access
750
+ preloadedLoaderData[href] = data
733
751
  return data
734
752
  })
735
753
  }
736
- return preloadingLoader[normalizedHref]
737
- }
738
754
 
739
- if (!preloadingLoader[href]) {
740
- preloadingLoader[href] = doPreload(href).then((data) => {
741
- // Store the resolved data for synchronous access
742
- preloadedLoaderData[href] = data
743
- return data
744
- })
745
- }
755
+ if (injectCSS) {
756
+ // Wait for preload to populate cssInjectFunctions, then inject CSS (max 800ms)
757
+ return preloadingLoader[href]?.then(async (data) => {
758
+ const inject = cssInjectFunctions[href]
759
+ if (inject) {
760
+ await Promise.race([inject(), new Promise((r) => setTimeout(r, 800))])
761
+ }
762
+ return data
763
+ })
764
+ }
746
765
 
747
- if (injectCSS) {
748
- // Wait for preload to populate cssInjectFunctions, then inject CSS (max 500ms)
749
- return preloadingLoader[href]?.then(async (data) => {
750
- const inject = cssInjectFunctions[href]
751
- if (inject) {
752
- await Promise.race([inject(), new Promise((r) => setTimeout(r, 500))])
753
- }
754
- return data
755
- })
766
+ return preloadingLoader[href]
756
767
  }
757
-
758
- return preloadingLoader[href]
759
768
  }
760
769
 
761
770
  // normalize path to match what useLoader uses for currentPath
@@ -797,6 +806,25 @@ function buildClientMatches(
797
806
  return [...layoutMatches, pageMatch]
798
807
  }
799
808
 
809
+ /**
810
+ * Build all matches for native, including layouts and page.
811
+ * Unlike web which preserves SSR-hydrated layouts, native builds fresh
812
+ * since there's no SSR context to hydrate from.
813
+ */
814
+ function buildNativeMatches(
815
+ state: OneRouter.ResultState,
816
+ pathname: string,
817
+ params: Record<string, string | string[]>
818
+ ): RouteMatch[] {
819
+ const allNodes = findAllRouteNodesFromState(state, routeNode)
820
+ return allNodes.map((node) => ({
821
+ routeId: node.contextKey || pathname,
822
+ pathname,
823
+ params,
824
+ loaderData: undefined, // loader data is fetched async by useLoader on native
825
+ }))
826
+ }
827
+
800
828
  /**
801
829
  * Initialize client matches from server context during hydration.
802
830
  * Called from createApp when hydrating.
@@ -958,15 +986,14 @@ export async function linkTo(
958
986
  }
959
987
 
960
988
  // Update client matches for useMatches hook
961
- // This runs after preload so loaderData is available
962
- if (process.env.TAMAGUI_TARGET !== 'native') {
963
- const normalizedPath = normalizeLoaderPath(href)
964
- const loaderData = preloadedLoaderData[normalizedPath]
965
- const params = extractParamsFromState(state)
966
- const newMatches = buildClientMatches(href, matchingRouteNode, params, loaderData)
967
- currentMatches = newMatches
968
- setClientMatches(newMatches)
969
- }
989
+ // On web: runs after preload so loaderData is available
990
+ // On native: runs without preloaded data (loaders are fetched by useLoader)
991
+ const normalizedPath = normalizeLoaderPath(href)
992
+ const loaderData = preloadedLoaderData[normalizedPath]
993
+ const params = extractParamsFromState(state)
994
+ const newMatches = buildClientMatches(href, matchingRouteNode, params, loaderData)
995
+ currentMatches = newMatches
996
+ setClientMatches(newMatches)
970
997
 
971
998
  const rootState = navigationRef.getRootState()
972
999