@timber-js/app 0.1.30 → 0.1.31

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.
@@ -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":"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"}
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
- * This is the client-side equivalent of deferSuspenseFor on the server:
15
- * the old content stays visible until the new content is ready, avoiding
16
- * flash-of-fallback during fast navigations.
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 no DOM elements returns the current element directly.
25
- * This means the DOM tree matches the server-rendered HTML during
26
- * hydration (TransitionRoot is invisible to the DOM).
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. React keeps the old committed tree
40
- * visible while any new Suspense boundaries in the update resolve.
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
- * Falls back to no-op if TransitionRoot hasn't mounted yet (shouldn't
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;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,EAA6B,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAalE;;;;;;;;;;;;;GAaG;AACH,wBAAgB,cAAc,CAAC,EAAE,OAAO,EAAE,EAAE;IAAE,OAAO,EAAE,SAAS,CAAA;CAAE,GAAG,SAAS,CAY7E;AAID;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,SAAS,GAAG,IAAI,CAIzD;AAED;;;GAGG;AACH,wBAAgB,qBAAqB,IAAI,OAAO,CAE/C"}
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,CA8B7E;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":"AAUA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,oBAAoB,IAAI,OAAO,CAK9C"}
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timber-js/app",
3
- "version": "0.1.30",
3
+ "version": "0.1.31",
4
4
  "description": "Vite-native React framework for Cloudflare Workers — correct HTTP semantics, real status codes, pages that work without JavaScript",
5
5
  "keywords": [
6
6
  "cloudflare-workers",
@@ -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
- // Wraps with NavigationProvider (for atomic useParams/usePathname updates)
374
- // and TimberNuqsAdapter (for nuqs context).
371
+ // Used for non-navigation renders (popstate cached replay, applyRevalidation).
372
+ // Wraps with NavigationProvider + TimberNuqsAdapter.
375
373
  //
376
- // The router calls setNavigationState() before renderRoot(), so
377
- // getNavigationState() returns the new params/pathname. By wrapping
378
- // the element in NavigationProvider here, the context value and the
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 NavigationContext so the pending status updates
7
- // atomically with params/pathname in the same React commit. This prevents
8
- // the gap where the spinner disappears before the active state updates.
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 { useNavigationContext } from './navigation-context.js';
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 NavigationContext and
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 navState = useNavigationContext();
30
- // During SSR or outside NavigationProvider, never pending
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: '/', pendingUrl: null };
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
+ }