@timber-js/app 0.1.30 → 0.1.32
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/dist/client/index.js +189 -141
- package/dist/client/index.js.map +1 -1
- package/dist/client/link-status-provider.d.ts +2 -8
- package/dist/client/link-status-provider.d.ts.map +1 -1
- package/dist/client/navigation-context.d.ts +0 -8
- package/dist/client/navigation-context.d.ts.map +1 -1
- package/dist/client/pending-navigation-context.d.ts +32 -0
- package/dist/client/pending-navigation-context.d.ts.map +1 -0
- package/dist/client/router.d.ts +12 -0
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/transition-root.d.ts +33 -13
- package/dist/client/transition-root.d.ts.map +1 -1
- package/dist/client/use-navigation-pending.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client/browser-entry.ts +26 -19
- package/src/client/link-status-provider.tsx +11 -15
- package/src/client/navigation-context.ts +1 -9
- package/src/client/pending-navigation-context.ts +66 -0
- package/src/client/router.ts +131 -98
- package/src/client/transition-root.tsx +88 -20
- package/src/client/use-navigation-pending.ts +7 -9
package/dist/client/router.d.ts
CHANGED
|
@@ -43,6 +43,18 @@ export interface RouterDeps {
|
|
|
43
43
|
afterPaint?: (callback: () => void) => void;
|
|
44
44
|
/** Apply resolved head elements (title, meta tags) to the DOM after navigation. */
|
|
45
45
|
applyHead?: (elements: HeadElement[]) => void;
|
|
46
|
+
/**
|
|
47
|
+
* Run a navigation inside a React transition with optimistic pending URL.
|
|
48
|
+
* The pending URL shows immediately (useOptimistic urgent update) and
|
|
49
|
+
* reverts when the transition commits (atomic with the new tree).
|
|
50
|
+
*
|
|
51
|
+
* The `perform` callback receives a `wrapPayload` function to wrap the
|
|
52
|
+
* decoded RSC payload with NavigationProvider + NuqsAdapter before
|
|
53
|
+
* TransitionRoot sets it as the new element.
|
|
54
|
+
*
|
|
55
|
+
* If not provided (tests), the router falls back to renderRoot.
|
|
56
|
+
*/
|
|
57
|
+
navigateTransition?: (pendingUrl: string, perform: (wrapPayload: (payload: unknown) => unknown) => Promise<unknown>) => Promise<void>;
|
|
46
58
|
}
|
|
47
59
|
export interface RouterInstance {
|
|
48
60
|
/** Navigate to a new URL (forward navigation) */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"router.d.ts","sourceRoot":"","sources":["../../src/client/router.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,YAAY,EAAE,aAAa,EAAoB,MAAM,iBAAiB,CAAC;AAChF,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AACnD,OAAO,EAAE,YAAY,EAAE,MAAM,WAAW,CAAC;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,QAAQ,CAAC;AAM1C,MAAM,WAAW,iBAAiB;IAChC,kEAAkE;IAClE,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,6EAA6E;IAC7E,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED;;;;GAIG;AACH,MAAM,MAAM,UAAU,GAAG,CAAC,YAAY,EAAE,OAAO,CAAC,QAAQ,CAAC,KAAK,OAAO,CAAC;AAEtE;;;;GAIG;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,OAAO,EAAE,OAAO,KAAK,IAAI,CAAC;AAEtD;;;GAGG;AACH,MAAM,WAAW,UAAU;IACzB,KAAK,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC7D,SAAS,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAChE,YAAY,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IACnE,QAAQ,EAAE,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;IACzC,aAAa,EAAE,MAAM,MAAM,CAAC;IAC5B,UAAU,EAAE,MAAM,MAAM,CAAC;IACzB,kGAAkG;IAClG,SAAS,CAAC,EAAE,UAAU,CAAC;IACvB,mFAAmF;IACnF,UAAU,CAAC,EAAE,YAAY,CAAC;IAC1B;;;;OAIG;IACH,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,IAAI,KAAK,IAAI,CAAC;IAC5C,mFAAmF;IACnF,SAAS,CAAC,EAAE,CAAC,QAAQ,EAAE,WAAW,EAAE,KAAK,IAAI,CAAC;
|
|
1
|
+
{"version":3,"file":"router.d.ts","sourceRoot":"","sources":["../../src/client/router.ts"],"names":[],"mappings":"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"}
|
|
@@ -11,41 +11,61 @@
|
|
|
11
11
|
* a transition update. React keeps the old committed tree visible while
|
|
12
12
|
* any new Suspense boundaries in the transition resolve.
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
* the
|
|
16
|
-
*
|
|
14
|
+
* Also manages `pendingUrl` via `useOptimistic`. During a navigation
|
|
15
|
+
* transition, the optimistic value (the target URL) shows immediately
|
|
16
|
+
* while the transition is pending, and automatically reverts to null
|
|
17
|
+
* when the transition commits. This ensures useLinkStatus and
|
|
18
|
+
* useNavigationPending show the pending state immediately and clear
|
|
19
|
+
* atomically with the new tree — same pattern Next.js uses with
|
|
20
|
+
* useOptimistic per Link instance, adapted for timber's server-component
|
|
21
|
+
* Link with global click delegation.
|
|
17
22
|
*
|
|
18
23
|
* See design/05-streaming.md §"deferSuspenseFor"
|
|
24
|
+
* See design/19-client-navigation.md §"NavigationContext"
|
|
19
25
|
*/
|
|
20
26
|
import { type ReactNode } from 'react';
|
|
21
27
|
/**
|
|
22
28
|
* Root wrapper component that enables transition-based rendering.
|
|
23
29
|
*
|
|
24
|
-
* Renders
|
|
25
|
-
*
|
|
26
|
-
*
|
|
30
|
+
* Renders PendingNavigationProvider around children for the pending URL
|
|
31
|
+
* context. The DOM tree matches the server-rendered HTML during hydration
|
|
32
|
+
* (the provider renders no extra DOM elements).
|
|
27
33
|
*
|
|
28
34
|
* Usage in browser-entry.ts:
|
|
29
35
|
* const rootEl = createElement(TransitionRoot, { initial: wrapped });
|
|
30
36
|
* reactRoot = hydrateRoot(document, rootEl);
|
|
31
37
|
*
|
|
32
38
|
* Subsequent navigations:
|
|
39
|
+
* navigateTransition(url, async () => { fetch; return wrappedElement; });
|
|
40
|
+
*
|
|
41
|
+
* Non-navigation renders:
|
|
33
42
|
* transitionRender(newWrappedElement);
|
|
34
43
|
*/
|
|
35
44
|
export declare function TransitionRoot({ initial }: {
|
|
36
45
|
initial: ReactNode;
|
|
37
46
|
}): ReactNode;
|
|
38
47
|
/**
|
|
39
|
-
* Trigger a transition render
|
|
40
|
-
* visible while any new Suspense
|
|
41
|
-
*
|
|
42
|
-
* This is the function called by the router's renderRoot callback
|
|
43
|
-
* instead of reactRoot.render() directly.
|
|
48
|
+
* Trigger a transition render for non-navigation updates.
|
|
49
|
+
* React keeps the old committed tree visible while any new Suspense
|
|
50
|
+
* boundaries in the update resolve.
|
|
44
51
|
*
|
|
45
|
-
*
|
|
46
|
-
* happen in practice — TransitionRoot mounts during hydration).
|
|
52
|
+
* Used for: applyRevalidation, popstate replay with cached payload.
|
|
47
53
|
*/
|
|
48
54
|
export declare function transitionRender(element: ReactNode): void;
|
|
55
|
+
/**
|
|
56
|
+
* Run a full navigation inside a React transition with optimistic pending URL.
|
|
57
|
+
*
|
|
58
|
+
* The `perform` callback runs inside `startTransition` — it should fetch the
|
|
59
|
+
* RSC payload, update router state, and return the wrapped React element.
|
|
60
|
+
* The pending URL shows immediately (useOptimistic urgent update) and reverts
|
|
61
|
+
* to null when the transition commits (atomic with the new tree).
|
|
62
|
+
*
|
|
63
|
+
* Returns a Promise that resolves when the async work completes (note: the
|
|
64
|
+
* React transition may not have committed yet, but all state updates are done).
|
|
65
|
+
*
|
|
66
|
+
* Used for: navigate(), refresh(), popstate with fetch.
|
|
67
|
+
*/
|
|
68
|
+
export declare function navigateTransition(pendingUrl: string, perform: () => Promise<ReactNode>): Promise<void>;
|
|
49
69
|
/**
|
|
50
70
|
* Check if the TransitionRoot is mounted and ready for renders.
|
|
51
71
|
* Used by browser-entry.ts to guard against renders before hydration.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"transition-root.d.ts","sourceRoot":"","sources":["../../src/client/transition-root.tsx"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"transition-root.d.ts","sourceRoot":"","sources":["../../src/client/transition-root.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,OAAO,EAKL,KAAK,SAAS,EACf,MAAM,OAAO,CAAC;AAuBf;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,cAAc,CAAC,EAAE,OAAO,EAAE,EAAE;IAAE,OAAO,EAAE,SAAS,CAAA;CAAE,GAAG,SAAS,CAkC7E;AAID;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,SAAS,GAAG,IAAI,CAIzD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,kBAAkB,CAChC,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,MAAM,OAAO,CAAC,SAAS,CAAC,GAChC,OAAO,CAAC,IAAI,CAAC,CAMf;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,IAAI,OAAO,CAE/C"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"use-navigation-pending.d.ts","sourceRoot":"","sources":["../../src/client/use-navigation-pending.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"use-navigation-pending.d.ts","sourceRoot":"","sources":["../../src/client/use-navigation-pending.ts"],"names":[],"mappings":"AASA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,oBAAoB,IAAI,OAAO,CAI9C"}
|
package/package.json
CHANGED
|
@@ -52,7 +52,7 @@ import { isPageUnloading } from './unload-guard.js';
|
|
|
52
52
|
import { NavigationProvider, getNavigationState, setNavigationState } from './navigation-context.js';
|
|
53
53
|
import { setupServerLogReplay, setupClientErrorForwarding } from './browser-dev.js';
|
|
54
54
|
import { handleLinkClick, handleLinkHover } from './browser-links.js';
|
|
55
|
-
import { TransitionRoot, transitionRender } from './transition-root.js';
|
|
55
|
+
import { TransitionRoot, transitionRender, navigateTransition } from './transition-root.js';
|
|
56
56
|
|
|
57
57
|
// ─── Server Action Dispatch ──────────────────────────────────────
|
|
58
58
|
|
|
@@ -289,14 +289,12 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
289
289
|
setNavigationState({
|
|
290
290
|
params: earlyParams as Record<string, string | string[]>,
|
|
291
291
|
pathname: window.location.pathname,
|
|
292
|
-
pendingUrl: null,
|
|
293
292
|
});
|
|
294
293
|
delete (self as unknown as Record<string, unknown>).__timber_params;
|
|
295
294
|
} else {
|
|
296
295
|
setNavigationState({
|
|
297
296
|
params: {},
|
|
298
297
|
pathname: window.location.pathname,
|
|
299
|
-
pendingUrl: null,
|
|
300
298
|
});
|
|
301
299
|
}
|
|
302
300
|
|
|
@@ -370,22 +368,12 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
370
368
|
},
|
|
371
369
|
|
|
372
370
|
// Render decoded RSC tree via TransitionRoot's state-based mechanism.
|
|
373
|
-
//
|
|
374
|
-
//
|
|
371
|
+
// Used for non-navigation renders (popstate cached replay, applyRevalidation).
|
|
372
|
+
// Wraps with NavigationProvider + TimberNuqsAdapter.
|
|
375
373
|
//
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
// RSC tree are passed to startTransition(() => setState()) in the same
|
|
380
|
-
// call — making the update atomic. Preserved layout components that call
|
|
381
|
-
// useParams() or usePathname() re-render in the same pass as the
|
|
382
|
-
// new tree, preventing the dual-active-state flash.
|
|
383
|
-
//
|
|
384
|
-
// Using transitionRender instead of reactRoot.render() enables
|
|
385
|
-
// client-side Suspense deferral: React keeps the old committed tree
|
|
386
|
-
// visible while new Suspense boundaries in the navigation resolve.
|
|
387
|
-
// This is the client-side equivalent of deferSuspenseFor on the server.
|
|
388
|
-
// See design/05-streaming.md.
|
|
374
|
+
// For navigation renders (navigate, refresh, popstate-with-fetch),
|
|
375
|
+
// navigateTransition is used instead — it wraps the entire navigation
|
|
376
|
+
// in a React transition with useOptimistic for the pending URL.
|
|
389
377
|
renderRoot: (element: unknown) => {
|
|
390
378
|
const navState = getNavigationState();
|
|
391
379
|
const withNav = createElement(NavigationProvider, { value: navState }, element as React.ReactNode);
|
|
@@ -393,6 +381,26 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
393
381
|
transitionRender(wrapped);
|
|
394
382
|
},
|
|
395
383
|
|
|
384
|
+
// Run a navigation inside a React transition with optimistic pending URL.
|
|
385
|
+
// The entire fetch + state update runs inside startTransition. useOptimistic
|
|
386
|
+
// shows the pending URL immediately and reverts to null when the transition
|
|
387
|
+
// commits (atomic with the new tree + params).
|
|
388
|
+
//
|
|
389
|
+
// The perform callback receives a wrapPayload function that wraps the
|
|
390
|
+
// decoded RSC payload with NavigationProvider + NuqsAdapter — this must
|
|
391
|
+
// happen inside the transition so the NavigationProvider reads the
|
|
392
|
+
// UPDATED navigation state (set by the router inside perform).
|
|
393
|
+
navigateTransition: (pendingUrl: string, perform) => {
|
|
394
|
+
return navigateTransition(pendingUrl, async () => {
|
|
395
|
+
const payload = await perform((rawPayload: unknown) => {
|
|
396
|
+
const navState = getNavigationState();
|
|
397
|
+
const withNav = createElement(NavigationProvider, { value: navState }, rawPayload as React.ReactNode);
|
|
398
|
+
return createElement(TimberNuqsAdapter, null, withNav);
|
|
399
|
+
});
|
|
400
|
+
return payload as React.ReactNode;
|
|
401
|
+
});
|
|
402
|
+
},
|
|
403
|
+
|
|
396
404
|
// Schedule a callback after the next paint so scroll operations
|
|
397
405
|
// happen after React commits the new content to the DOM.
|
|
398
406
|
// Double-rAF ensures the browser has painted the new frame.
|
|
@@ -441,7 +449,6 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
441
449
|
setNavigationState({
|
|
442
450
|
params: lateTimberParams as Record<string, string | string[]>,
|
|
443
451
|
pathname: window.location.pathname,
|
|
444
|
-
pendingUrl: null,
|
|
445
452
|
});
|
|
446
453
|
delete (self as unknown as Record<string, unknown>).__timber_params;
|
|
447
454
|
}
|
|
@@ -3,32 +3,28 @@
|
|
|
3
3
|
// LinkStatusProvider — client component that provides per-link pending status
|
|
4
4
|
// via React context. Used inside <Link> to power useLinkStatus().
|
|
5
5
|
//
|
|
6
|
-
// Reads pendingUrl from
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// Reads pendingUrl from PendingNavigationContext (provided by TransitionRoot).
|
|
7
|
+
// The pending URL is set as an URGENT update at navigation start (shows
|
|
8
|
+
// immediately) and cleared inside startTransition when the new tree commits
|
|
9
|
+
// (atomic with params/pathname). This eliminates both:
|
|
10
|
+
// 1. The delay before showing the spinner (urgent update, not deferred)
|
|
11
|
+
// 2. The gap between spinner disappearing and active state updating (same commit)
|
|
9
12
|
|
|
10
13
|
import type { ReactNode } from 'react';
|
|
11
14
|
import { LinkStatusContext, type LinkStatus } from './use-link-status.js';
|
|
12
|
-
import {
|
|
15
|
+
import { usePendingNavigationUrl } from './pending-navigation-context.js';
|
|
13
16
|
|
|
14
17
|
const NOT_PENDING: LinkStatus = { pending: false };
|
|
15
18
|
const IS_PENDING: LinkStatus = { pending: true };
|
|
16
19
|
|
|
17
20
|
/**
|
|
18
|
-
* Client component that reads the pending URL from
|
|
19
|
-
* provides a scoped LinkStatusContext to children. Renders no extra DOM —
|
|
21
|
+
* Client component that reads the pending URL from PendingNavigationContext
|
|
22
|
+
* and provides a scoped LinkStatusContext to children. Renders no extra DOM —
|
|
20
23
|
* just a context provider around children.
|
|
21
|
-
*
|
|
22
|
-
* Because pendingUrl lives in NavigationContext alongside params and pathname,
|
|
23
|
-
* all three update in the same React commit via renderRoot(). This eliminates
|
|
24
|
-
* the two-commit timing gap that existed when pendingUrl was read via
|
|
25
|
-
* useSyncExternalStore (external module-level state) while params came from
|
|
26
|
-
* NavigationContext (React context).
|
|
27
24
|
*/
|
|
28
25
|
export function LinkStatusProvider({ href, children }: { href: string; children: ReactNode }) {
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
const status = navState?.pendingUrl === href ? IS_PENDING : NOT_PENDING;
|
|
26
|
+
const pendingUrl = usePendingNavigationUrl();
|
|
27
|
+
const status = pendingUrl === href ? IS_PENDING : NOT_PENDING;
|
|
32
28
|
|
|
33
29
|
return <LinkStatusContext.Provider value={status}>{children}</LinkStatusContext.Provider>;
|
|
34
30
|
}
|
|
@@ -35,14 +35,6 @@ import React, { createElement, type ReactNode } from 'react';
|
|
|
35
35
|
export interface NavigationState {
|
|
36
36
|
params: Record<string, string | string[]>;
|
|
37
37
|
pathname: string;
|
|
38
|
-
/**
|
|
39
|
-
* The URL currently being navigated to, or null if idle.
|
|
40
|
-
* Used by LinkStatusProvider to determine pending status atomically
|
|
41
|
-
* with params/pathname — all three update in the same React commit
|
|
42
|
-
* via NavigationProvider, preventing the gap where the spinner
|
|
43
|
-
* disappears before the active state updates.
|
|
44
|
-
*/
|
|
45
|
-
pendingUrl: string | null;
|
|
46
38
|
}
|
|
47
39
|
|
|
48
40
|
// ---------------------------------------------------------------------------
|
|
@@ -115,7 +107,7 @@ export function NavigationProvider({ value, children }: NavigationProviderProps)
|
|
|
115
107
|
* This exists only as a communication channel between the router
|
|
116
108
|
* (which knows the new nav state) and renderRoot (which wraps the element).
|
|
117
109
|
*/
|
|
118
|
-
let _currentNavState: NavigationState = { params: {}, pathname: '/'
|
|
110
|
+
let _currentNavState: NavigationState = { params: {}, pathname: '/' };
|
|
119
111
|
|
|
120
112
|
export function setNavigationState(state: NavigationState): void {
|
|
121
113
|
_currentNavState = state;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PendingNavigationContext — React context for the in-flight navigation URL.
|
|
3
|
+
*
|
|
4
|
+
* Provided by TransitionRoot. The value is the URL being navigated to,
|
|
5
|
+
* or null when idle. Used by:
|
|
6
|
+
* - LinkStatusProvider to show per-link pending spinners
|
|
7
|
+
* - useNavigationPending to return a global pending boolean
|
|
8
|
+
*
|
|
9
|
+
* The pending URL is set as an URGENT update (shows immediately) and
|
|
10
|
+
* cleared inside startTransition (commits atomically with the new tree).
|
|
11
|
+
* This ensures pending state appears instantly on navigation start and
|
|
12
|
+
* disappears in the same React commit as the new params/tree.
|
|
13
|
+
*
|
|
14
|
+
* Separate from NavigationContext (which holds params + pathname) because
|
|
15
|
+
* the pending URL is managed as React state in TransitionRoot, while
|
|
16
|
+
* params/pathname are set via module-level state read by renderRoot.
|
|
17
|
+
* Both contexts commit together in the same transition.
|
|
18
|
+
*
|
|
19
|
+
* See design/19-client-navigation.md §"NavigationContext"
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import React, { createElement, type ReactNode } from 'react';
|
|
23
|
+
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
// Lazy context initialization (same pattern as NavigationContext)
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
let _context: React.Context<string | null> | undefined;
|
|
29
|
+
|
|
30
|
+
function getOrCreateContext(): React.Context<string | null> | undefined {
|
|
31
|
+
if (_context !== undefined) return _context;
|
|
32
|
+
if (typeof React.createContext === 'function') {
|
|
33
|
+
_context = React.createContext<string | null>(null);
|
|
34
|
+
}
|
|
35
|
+
return _context;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Read the pending navigation URL from context.
|
|
40
|
+
* Returns null during SSR (no provider) or in the RSC environment.
|
|
41
|
+
* Internal — used by LinkStatusProvider and useNavigationPending.
|
|
42
|
+
*/
|
|
43
|
+
export function usePendingNavigationUrl(): string | null {
|
|
44
|
+
const ctx = getOrCreateContext();
|
|
45
|
+
if (!ctx) return null;
|
|
46
|
+
if (typeof React.useContext !== 'function') return null;
|
|
47
|
+
return React.useContext(ctx);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Provider component
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
export function PendingNavigationProvider({
|
|
55
|
+
value,
|
|
56
|
+
children,
|
|
57
|
+
}: {
|
|
58
|
+
value: string | null;
|
|
59
|
+
children?: ReactNode;
|
|
60
|
+
}): React.ReactElement {
|
|
61
|
+
const ctx = getOrCreateContext();
|
|
62
|
+
if (!ctx) {
|
|
63
|
+
return children as React.ReactElement;
|
|
64
|
+
}
|
|
65
|
+
return createElement(ctx.Provider, { value }, children);
|
|
66
|
+
}
|