X Tutup
Skip to content

Commit c0cb80d

Browse files
committed
feat: improve deterministic page navigation handling
1 parent 291409c commit c0cb80d

File tree

7 files changed

+252
-65
lines changed

7 files changed

+252
-65
lines changed

packages/tanstack-router/solid-web-shim.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,14 @@ export function pipeToNodeWritable() {}
8181
export function pipeToWritable() {}
8282
export function addEventListener() {}
8383
export function removeEventListener() {}
84+
export function dynamicProperty() {}
85+
export function setAttributeNS() {}
86+
export const Aliases = {};
87+
export function getPropAlias(prop) {
88+
return prop;
89+
}
90+
export const Properties = new Set();
91+
export const ChildProperties = new Set();
92+
export const DelegatedEvents = new Set();
93+
export const SVGElements = new Set();
94+
export const SVGNamespace = {};

packages/tanstack-router/src/NativeScriptRouterProvider.tsx

Lines changed: 49 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ import {
1212
getNavigationKind,
1313
shouldSkipPathNavigation,
1414
} from './navigation-state'
15-
import { getNativeBackCallbackDecision } from './native-back-sync'
15+
import {
16+
getNativeBackCallbackDecision,
17+
shouldCompleteNativeBackSyncOnVisiblePath,
18+
} from './native-back-sync'
1619
import { createDebugLogger } from './debug-log'
1720
import type {
1821
NavigationGuard,
@@ -46,7 +49,6 @@ export function NativeScriptRouterProvider(props: NativeScriptRouterProviderProp
4649
let suppressedNativeBackCallbacks = 0
4750
let nativeBackSyncInFlight = false
4851
let nativeBackSyncFromPath: string | null = null
49-
let nativeBackSyncTimeoutId: ReturnType<typeof setTimeout> | undefined
5052
let queuedNativeBackCount = 0
5153
let skipNextFrameBackNavigation = false
5254
// Shared guard to prevent circular updates between router and Frame
@@ -298,30 +300,26 @@ export function NativeScriptRouterProvider(props: NativeScriptRouterProviderProp
298300
acquireGuard('native_back_callback')
299301
log('[NSRouter] native back pop -> router.history.back()')
300302
router.history.back()
301-
302-
if (nativeBackSyncTimeoutId) {
303-
clearTimeout(nativeBackSyncTimeoutId)
304-
}
305-
306-
// Failsafe: if router state does not settle quickly, unlock and continue.
307-
nativeBackSyncTimeoutId = setTimeout(() => {
308-
if (!nativeBackSyncInFlight) {
309-
return
310-
}
311-
312-
log('[NSRouter] native back sync timeout; forcing in-flight release')
313-
nativeBackSyncInFlight = false
314-
nativeBackSyncFromPath = null
315-
if (guard.lockReason === 'native_back_callback') {
316-
releaseGuard()
317-
}
318-
setTimeout(tryDrainQueuedNativeBack, 0)
319-
}, 250)
320303
}
321304

322305
const reconcileRouterToVisiblePath = (visiblePath: string) => {
323306
const activePath = router.state.location.pathname
324307
if (visiblePath === activePath) {
308+
// Native visibility has aligned with router path, so any in-flight native
309+
// back sync can be deterministically completed from this event.
310+
if (shouldCompleteNativeBackSyncOnVisiblePath({
311+
inFlight: nativeBackSyncInFlight,
312+
visiblePath,
313+
activePath,
314+
})) {
315+
nativeBackSyncInFlight = false
316+
nativeBackSyncFromPath = null
317+
if (guard.lockReason === 'native_back_callback') {
318+
log('[NSRouter] native back sync completed on visible-path alignment')
319+
releaseGuard()
320+
}
321+
setTimeout(tryDrainQueuedNativeBack, 0)
322+
}
325323
return
326324
}
327325

@@ -337,17 +335,42 @@ export function NativeScriptRouterProvider(props: NativeScriptRouterProviderProp
337335
nativeBackSyncFromPath = null
338336
queuedNativeBackCount = 0
339337
skipNextFrameBackNavigation = false
340-
if (nativeBackSyncTimeoutId) {
341-
clearTimeout(nativeBackSyncTimeoutId)
342-
nativeBackSyncTimeoutId = undefined
343-
}
344338

345339
acquireGuard('native_visible_path_reconcile')
346340
router.navigate({
347341
to: visiblePath,
348342
replace: true,
349343
} as any)
350-
setTimeout(releaseGuard, 0)
344+
345+
const settlePromise = router.load?.()
346+
347+
if (settlePromise && typeof settlePromise.then === 'function') {
348+
settlePromise
349+
.then(() => {
350+
log(
351+
'[NSRouter] visible-path reconcile settled:',
352+
'status=',
353+
router.state.status,
354+
'path=',
355+
router.state.location.pathname,
356+
)
357+
})
358+
.catch((err: any) => {
359+
console.error('[NSRouter] visible-path reconcile load rejected:', err)
360+
})
361+
.finally(() => {
362+
if (guard.lockReason === 'native_visible_path_reconcile') {
363+
releaseGuard()
364+
}
365+
setTimeout(tryDrainQueuedNativeBack, 0)
366+
})
367+
return
368+
}
369+
370+
if (guard.lockReason === 'native_visible_path_reconcile') {
371+
releaseGuard()
372+
}
373+
setTimeout(tryDrainQueuedNativeBack, 0)
351374
}
352375

353376
const tryDrainQueuedNativeBack = () => {
@@ -392,10 +415,6 @@ export function NativeScriptRouterProvider(props: NativeScriptRouterProviderProp
392415
const cleanupBack = setupBackHandler(router, () => frameRef, guard)
393416

394417
onCleanup(() => {
395-
if (nativeBackSyncTimeoutId) {
396-
clearTimeout(nativeBackSyncTimeoutId)
397-
nativeBackSyncTimeoutId = undefined
398-
}
399418
closeModalFromRouterState()
400419
unsub()
401420
cleanupBack()
@@ -424,10 +443,6 @@ export function NativeScriptRouterProvider(props: NativeScriptRouterProviderProp
424443
) {
425444
nativeBackSyncInFlight = false
426445
nativeBackSyncFromPath = null
427-
if (nativeBackSyncTimeoutId) {
428-
clearTimeout(nativeBackSyncTimeoutId)
429-
nativeBackSyncTimeoutId = undefined
430-
}
431446
if (guard.lockReason === 'native_back_callback') {
432447
log('[NSRouter] native back callback releasing guard')
433448
releaseGuard()

packages/tanstack-router/src/PageRenderer.tsx

Lines changed: 5 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
import { createDebugLogger } from './debug-log'
1010
import { describeComponentShape, normalizeRenderableComponent } from './component-shape'
1111
import { shouldUnmountPageOnNavigatingFrom } from './page-lifecycle'
12+
import { NavigatedData } from '@nativescript/core'
1213

1314
function getErrorMessage(err: unknown): string {
1415
if (err instanceof Error) {
@@ -40,36 +41,9 @@ export function renderPage(
4041
const loggedUnexpectedErrors = new Set<string>()
4142
let dispose: (() => void) | undefined
4243
let nativeBackSyncScheduled = false
43-
let remountTimer: ReturnType<typeof setTimeout> | undefined
44-
45-
const clearRemountTimer = () => {
46-
if (remountTimer) {
47-
clearTimeout(remountTimer)
48-
remountTimer = undefined
49-
}
50-
}
5144

5245
const getPagePath = () => (page as any).__nsRouterPath || routePath
5346

54-
const remountWhenRouterPathSettles = (attempt = 0) => {
55-
clearRemountTimer()
56-
57-
const pagePath = getPagePath()
58-
const activePath = router.state.location.pathname
59-
const isAligned = activePath === pagePath
60-
61-
if (isAligned || !onNativeBack) {
62-
mount()
63-
return
64-
}
65-
66-
if (attempt === 0) {
67-
log('[NSRouter] deferring page remount until router path settles:', 'page=', pagePath, 'active=', activePath)
68-
}
69-
70-
remountTimer = setTimeout(() => remountWhenRouterPathSettles(attempt + 1), 0)
71-
}
72-
7347
// Reset error boundary when Page comes back from backstack
7448
page.on('loaded', () => {
7549
if (resetErrorBoundary) {
@@ -165,13 +139,15 @@ export function renderPage(
165139
page.on('navigatedTo', () => {
166140
nativeBackSyncScheduled = resetNativeBackSyncScheduled()
167141
onVisiblePathChange?.(getPagePath())
168-
remountWhenRouterPathSettles()
142+
// Deterministic lifecycle handling: once a Page is visible, it must have
143+
// an active view tree regardless of router pending/transient state.
144+
mount()
169145
log('[NSRouter] page navigatedTo:', getPagePath())
170146
})
171147

172148
// Sync router when this page is popped by native UI back controls
173149
// (iOS ActionBar/UINavigationController back, swipe-back, Android native pop).
174-
page.on('navigatingFrom', (args: any) => {
150+
page.on('navigatingFrom', (args: NavigatedData) => {
175151
const isBack = !!args?.isBackNavigation
176152

177153
// Tear down hidden page trees on both forward/back transitions.
@@ -204,7 +180,6 @@ export function renderPage(
204180

205181
// Clean up SolidJS tree when native Page is destroyed
206182
page.on('disposeNativeView', () => {
207-
clearRemountTimer()
208183
nativeBackSyncScheduled = resetNativeBackSyncScheduled()
209184
unmount()
210185
})

packages/tanstack-router/src/native-back-sync.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
11
export type NativeBackCallbackDecision = 'run' | 'ignore_guard_active' | 'ignore_cannot_go_back';
22

3+
export interface NativeBackTimeoutReconcileOptions {
4+
visiblePath?: string | null;
5+
activePath: string;
6+
}
7+
8+
export interface NativeBackVisiblePathCompletionOptions {
9+
inFlight: boolean;
10+
visiblePath: string;
11+
activePath: string;
12+
}
13+
314
export function getNativeBackCallbackDecision(opts: { guardActive: boolean; canGoBack: boolean }): NativeBackCallbackDecision {
415
if (opts.guardActive) {
516
return 'ignore_guard_active';
@@ -19,3 +30,24 @@ export function shouldScheduleNativeBackSync(opts: { isBackNavigation: boolean;
1930
export function resetNativeBackSyncScheduled(): false {
2031
return false;
2132
}
33+
34+
export function getNativeBackTimeoutReconcilePath(opts: NativeBackTimeoutReconcileOptions): string | null {
35+
if (typeof opts.visiblePath !== 'string') {
36+
return null;
37+
}
38+
39+
const normalizedVisiblePath = opts.visiblePath.trim();
40+
if (!normalizedVisiblePath) {
41+
return null;
42+
}
43+
44+
if (normalizedVisiblePath === opts.activePath) {
45+
return null;
46+
}
47+
48+
return normalizedVisiblePath;
49+
}
50+
51+
export function shouldCompleteNativeBackSyncOnVisiblePath(opts: NativeBackVisiblePathCompletionOptions): boolean {
52+
return opts.inFlight && opts.visiblePath === opts.activePath;
53+
}

packages/tanstack-router/src/solid-web-shim.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,14 @@ export function pipeToNodeWritable() {}
8585
export function pipeToWritable() {}
8686
export function addEventListener() {}
8787
export function removeEventListener() {}
88+
export function dynamicProperty() {}
89+
export function setAttributeNS() {}
90+
export const Aliases = {};
91+
export function getPropAlias(prop) {
92+
return prop;
93+
}
94+
export const Properties = new Set();
95+
export const ChildProperties = new Set();
96+
export const DelegatedEvents = new Set();
97+
export const SVGElements = new Set();
98+
export const SVGNamespace = {};

packages/tanstack-router/test/native-back-sync.test.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from 'vitest';
2-
import { getNativeBackCallbackDecision, resetNativeBackSyncScheduled, shouldScheduleNativeBackSync } from '../src/native-back-sync';
2+
import { getNativeBackCallbackDecision, getNativeBackTimeoutReconcilePath, resetNativeBackSyncScheduled, shouldCompleteNativeBackSyncOnVisiblePath, shouldScheduleNativeBackSync } from '../src/native-back-sync';
33

44
describe('native-back-sync helpers', () => {
55
describe('getNativeBackCallbackDecision', () => {
@@ -63,4 +63,72 @@ describe('native-back-sync helpers', () => {
6363
expect(resetNativeBackSyncScheduled()).toBe(false);
6464
});
6565
});
66+
67+
describe('getNativeBackTimeoutReconcilePath', () => {
68+
it('returns visible path when router path is stale after timeout', () => {
69+
expect(
70+
getNativeBackTimeoutReconcilePath({
71+
visiblePath: '/',
72+
activePath: '/posts/1',
73+
}),
74+
).toBe('/');
75+
});
76+
77+
it('returns null when visible path matches active path', () => {
78+
expect(
79+
getNativeBackTimeoutReconcilePath({
80+
visiblePath: '/posts/1',
81+
activePath: '/posts/1',
82+
}),
83+
).toBeNull();
84+
});
85+
86+
it('returns null for empty or missing visible path', () => {
87+
expect(
88+
getNativeBackTimeoutReconcilePath({
89+
visiblePath: ' ',
90+
activePath: '/posts/1',
91+
}),
92+
).toBeNull();
93+
94+
expect(
95+
getNativeBackTimeoutReconcilePath({
96+
visiblePath: undefined,
97+
activePath: '/posts/1',
98+
}),
99+
).toBeNull();
100+
});
101+
});
102+
103+
describe('shouldCompleteNativeBackSyncOnVisiblePath', () => {
104+
it('completes in-flight sync when visible and active paths align', () => {
105+
expect(
106+
shouldCompleteNativeBackSyncOnVisiblePath({
107+
inFlight: true,
108+
visiblePath: '/',
109+
activePath: '/',
110+
}),
111+
).toBe(true);
112+
});
113+
114+
it('does not complete when sync is not in flight', () => {
115+
expect(
116+
shouldCompleteNativeBackSyncOnVisiblePath({
117+
inFlight: false,
118+
visiblePath: '/',
119+
activePath: '/',
120+
}),
121+
).toBe(false);
122+
});
123+
124+
it('does not complete when paths are still mismatched', () => {
125+
expect(
126+
shouldCompleteNativeBackSyncOnVisiblePath({
127+
inFlight: true,
128+
visiblePath: '/',
129+
activePath: '/posts/1',
130+
}),
131+
).toBe(false);
132+
});
133+
});
66134
});

0 commit comments

Comments
 (0)
X Tutup