@rangojs/router 0.0.0-experimental.10

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 (172) hide show
  1. package/CLAUDE.md +43 -0
  2. package/README.md +19 -0
  3. package/dist/bin/rango.js +227 -0
  4. package/dist/vite/index.js +3039 -0
  5. package/package.json +171 -0
  6. package/skills/caching/SKILL.md +191 -0
  7. package/skills/debug-manifest/SKILL.md +108 -0
  8. package/skills/document-cache/SKILL.md +180 -0
  9. package/skills/fonts/SKILL.md +165 -0
  10. package/skills/hooks/SKILL.md +442 -0
  11. package/skills/intercept/SKILL.md +190 -0
  12. package/skills/layout/SKILL.md +213 -0
  13. package/skills/links/SKILL.md +180 -0
  14. package/skills/loader/SKILL.md +246 -0
  15. package/skills/middleware/SKILL.md +202 -0
  16. package/skills/mime-routes/SKILL.md +124 -0
  17. package/skills/parallel/SKILL.md +228 -0
  18. package/skills/prerender/SKILL.md +283 -0
  19. package/skills/rango/SKILL.md +54 -0
  20. package/skills/response-routes/SKILL.md +358 -0
  21. package/skills/route/SKILL.md +173 -0
  22. package/skills/router-setup/SKILL.md +346 -0
  23. package/skills/tailwind/SKILL.md +129 -0
  24. package/skills/theme/SKILL.md +78 -0
  25. package/skills/typesafety/SKILL.md +394 -0
  26. package/src/__internal.ts +175 -0
  27. package/src/bin/rango.ts +24 -0
  28. package/src/browser/event-controller.ts +876 -0
  29. package/src/browser/index.ts +18 -0
  30. package/src/browser/link-interceptor.ts +121 -0
  31. package/src/browser/lru-cache.ts +69 -0
  32. package/src/browser/merge-segment-loaders.ts +126 -0
  33. package/src/browser/navigation-bridge.ts +913 -0
  34. package/src/browser/navigation-client.ts +165 -0
  35. package/src/browser/navigation-store.ts +823 -0
  36. package/src/browser/partial-update.ts +600 -0
  37. package/src/browser/react/Link.tsx +248 -0
  38. package/src/browser/react/NavigationProvider.tsx +346 -0
  39. package/src/browser/react/ScrollRestoration.tsx +94 -0
  40. package/src/browser/react/context.ts +53 -0
  41. package/src/browser/react/index.ts +52 -0
  42. package/src/browser/react/location-state-shared.ts +120 -0
  43. package/src/browser/react/location-state.ts +62 -0
  44. package/src/browser/react/mount-context.ts +32 -0
  45. package/src/browser/react/use-action.ts +240 -0
  46. package/src/browser/react/use-client-cache.ts +56 -0
  47. package/src/browser/react/use-handle.ts +203 -0
  48. package/src/browser/react/use-href.tsx +40 -0
  49. package/src/browser/react/use-link-status.ts +134 -0
  50. package/src/browser/react/use-mount.ts +31 -0
  51. package/src/browser/react/use-navigation.ts +140 -0
  52. package/src/browser/react/use-segments.ts +188 -0
  53. package/src/browser/request-controller.ts +164 -0
  54. package/src/browser/rsc-router.tsx +352 -0
  55. package/src/browser/scroll-restoration.ts +324 -0
  56. package/src/browser/segment-structure-assert.ts +67 -0
  57. package/src/browser/server-action-bridge.ts +762 -0
  58. package/src/browser/shallow.ts +35 -0
  59. package/src/browser/types.ts +478 -0
  60. package/src/build/generate-manifest.ts +377 -0
  61. package/src/build/generate-route-types.ts +828 -0
  62. package/src/build/index.ts +36 -0
  63. package/src/build/route-trie.ts +239 -0
  64. package/src/cache/cache-scope.ts +563 -0
  65. package/src/cache/cf/cf-cache-store.ts +428 -0
  66. package/src/cache/cf/index.ts +19 -0
  67. package/src/cache/document-cache.ts +340 -0
  68. package/src/cache/index.ts +58 -0
  69. package/src/cache/memory-segment-store.ts +150 -0
  70. package/src/cache/memory-store.ts +253 -0
  71. package/src/cache/types.ts +392 -0
  72. package/src/client.rsc.tsx +83 -0
  73. package/src/client.tsx +643 -0
  74. package/src/component-utils.ts +76 -0
  75. package/src/components/DefaultDocument.tsx +23 -0
  76. package/src/debug.ts +233 -0
  77. package/src/default-error-boundary.tsx +88 -0
  78. package/src/deps/browser.ts +8 -0
  79. package/src/deps/html-stream-client.ts +2 -0
  80. package/src/deps/html-stream-server.ts +2 -0
  81. package/src/deps/rsc.ts +10 -0
  82. package/src/deps/ssr.ts +2 -0
  83. package/src/errors.ts +295 -0
  84. package/src/handle.ts +130 -0
  85. package/src/handles/MetaTags.tsx +193 -0
  86. package/src/handles/index.ts +6 -0
  87. package/src/handles/meta.ts +247 -0
  88. package/src/host/cookie-handler.ts +159 -0
  89. package/src/host/errors.ts +97 -0
  90. package/src/host/index.ts +56 -0
  91. package/src/host/pattern-matcher.ts +214 -0
  92. package/src/host/router.ts +330 -0
  93. package/src/host/testing.ts +79 -0
  94. package/src/host/types.ts +138 -0
  95. package/src/host/utils.ts +25 -0
  96. package/src/href-client.ts +202 -0
  97. package/src/href-context.ts +33 -0
  98. package/src/index.rsc.ts +121 -0
  99. package/src/index.ts +165 -0
  100. package/src/loader.rsc.ts +207 -0
  101. package/src/loader.ts +47 -0
  102. package/src/network-error-thrower.tsx +21 -0
  103. package/src/outlet-context.ts +15 -0
  104. package/src/prerender/param-hash.ts +35 -0
  105. package/src/prerender/store.ts +40 -0
  106. package/src/prerender.ts +156 -0
  107. package/src/reverse.ts +267 -0
  108. package/src/root-error-boundary.tsx +277 -0
  109. package/src/route-content-wrapper.tsx +193 -0
  110. package/src/route-definition.ts +1431 -0
  111. package/src/route-map-builder.ts +242 -0
  112. package/src/route-types.ts +220 -0
  113. package/src/router/error-handling.ts +287 -0
  114. package/src/router/handler-context.ts +158 -0
  115. package/src/router/intercept-resolution.ts +387 -0
  116. package/src/router/loader-resolution.ts +327 -0
  117. package/src/router/manifest.ts +216 -0
  118. package/src/router/match-api.ts +621 -0
  119. package/src/router/match-context.ts +264 -0
  120. package/src/router/match-middleware/background-revalidation.ts +236 -0
  121. package/src/router/match-middleware/cache-lookup.ts +382 -0
  122. package/src/router/match-middleware/cache-store.ts +276 -0
  123. package/src/router/match-middleware/index.ts +81 -0
  124. package/src/router/match-middleware/intercept-resolution.ts +281 -0
  125. package/src/router/match-middleware/segment-resolution.ts +184 -0
  126. package/src/router/match-pipelines.ts +214 -0
  127. package/src/router/match-result.ts +213 -0
  128. package/src/router/metrics.ts +62 -0
  129. package/src/router/middleware.ts +791 -0
  130. package/src/router/pattern-matching.ts +407 -0
  131. package/src/router/revalidation.ts +190 -0
  132. package/src/router/router-context.ts +301 -0
  133. package/src/router/segment-resolution.ts +1315 -0
  134. package/src/router/trie-matching.ts +172 -0
  135. package/src/router/types.ts +163 -0
  136. package/src/router.gen.ts +6 -0
  137. package/src/router.ts +2423 -0
  138. package/src/rsc/handler.ts +1443 -0
  139. package/src/rsc/helpers.ts +64 -0
  140. package/src/rsc/index.ts +56 -0
  141. package/src/rsc/nonce.ts +18 -0
  142. package/src/rsc/types.ts +236 -0
  143. package/src/segment-system.tsx +442 -0
  144. package/src/server/context.ts +466 -0
  145. package/src/server/handle-store.ts +229 -0
  146. package/src/server/loader-registry.ts +174 -0
  147. package/src/server/request-context.ts +554 -0
  148. package/src/server/root-layout.tsx +10 -0
  149. package/src/server/tsconfig.json +14 -0
  150. package/src/server.ts +171 -0
  151. package/src/ssr/index.tsx +296 -0
  152. package/src/theme/ThemeProvider.tsx +291 -0
  153. package/src/theme/ThemeScript.tsx +61 -0
  154. package/src/theme/constants.ts +59 -0
  155. package/src/theme/index.ts +58 -0
  156. package/src/theme/theme-context.ts +70 -0
  157. package/src/theme/theme-script.ts +152 -0
  158. package/src/theme/types.ts +182 -0
  159. package/src/theme/use-theme.ts +44 -0
  160. package/src/types.ts +1757 -0
  161. package/src/urls.gen.ts +8 -0
  162. package/src/urls.ts +1282 -0
  163. package/src/use-loader.tsx +346 -0
  164. package/src/vite/expose-action-id.ts +344 -0
  165. package/src/vite/expose-handle-id.ts +209 -0
  166. package/src/vite/expose-loader-id.ts +426 -0
  167. package/src/vite/expose-location-state-id.ts +177 -0
  168. package/src/vite/expose-prerender-handler-id.ts +429 -0
  169. package/src/vite/index.ts +2068 -0
  170. package/src/vite/package-resolution.ts +125 -0
  171. package/src/vite/version.d.ts +12 -0
  172. package/src/vite/virtual-entries.ts +114 -0
@@ -0,0 +1,53 @@
1
+ "use client";
2
+
3
+ import { createContext, type Context } from "react";
4
+ import type { NavigationStore, NavigateOptions } from "../types.js";
5
+ import type { EventController } from "../event-controller.js";
6
+
7
+ /**
8
+ * Navigation context value provided by NavigationProvider
9
+ *
10
+ * This context provides a STABLE reference to the store, event controller, and methods.
11
+ * The store itself never changes, so context consumers don't re-render
12
+ * when navigation state changes.
13
+ *
14
+ * Components subscribe to state changes via eventController.subscribe() in useNavigation.
15
+ */
16
+ export interface NavigationStoreContextValue {
17
+ /**
18
+ * The navigation store instance (stable reference)
19
+ * Used for cache/segment management
20
+ */
21
+ store: NavigationStore;
22
+
23
+ /**
24
+ * The event controller instance (stable reference)
25
+ * Used for navigation/action state
26
+ */
27
+ eventController: EventController;
28
+
29
+ /**
30
+ * Navigate to a new URL
31
+ *
32
+ * @param url - The URL to navigate to
33
+ * @param options - Navigation options (replace, scroll)
34
+ * @returns Promise that resolves when navigation is complete
35
+ */
36
+ navigate: (url: string, options?: NavigateOptions) => Promise<void>;
37
+
38
+ /**
39
+ * Refresh the current route
40
+ *
41
+ * @returns Promise that resolves when refresh is complete
42
+ */
43
+ refresh: () => Promise<void>;
44
+ }
45
+
46
+ /**
47
+ * React context for navigation store
48
+ *
49
+ * Provides stable reference to the store - does NOT re-render on state changes.
50
+ * Use useNavigation hook for reactive state access.
51
+ */
52
+ export const NavigationStoreContext: Context<NavigationStoreContextValue | null> =
53
+ createContext<NavigationStoreContextValue | null>(null);
@@ -0,0 +1,52 @@
1
+ // React exports for browser navigation
2
+
3
+ // Hook with Zustand-style selectors
4
+ export {
5
+ useNavigation,
6
+ type NavigationMethods,
7
+ type NavigationValue,
8
+ } from "./use-navigation.js";
9
+
10
+ // Action state tracking hook
11
+ export { useAction, type TrackedActionState } from "./use-action.js";
12
+
13
+ // Segments state hook
14
+ export { useSegments, initSegmentsSync, type SegmentsState } from "./use-segments.js";
15
+
16
+ // Handle data hook
17
+ export { useHandle, initHandleDataSync } from "./use-handle.js";
18
+
19
+ // Client cache controls hook
20
+ export {
21
+ useClientCache,
22
+ type ClientCacheControls,
23
+ } from "./use-client-cache.js";
24
+
25
+ // Provider
26
+ export {
27
+ NavigationProvider,
28
+ type NavigationProviderProps,
29
+ } from "./NavigationProvider.js";
30
+
31
+ // Context (for advanced usage)
32
+ export {
33
+ NavigationStoreContext,
34
+ type NavigationStoreContextValue,
35
+ } from "./context.js";
36
+
37
+ // Link component
38
+ export {
39
+ Link,
40
+ type LinkProps,
41
+ type PrefetchStrategy,
42
+ } from "./Link.js";
43
+
44
+ // Link status hook
45
+ export { useLinkStatus, type LinkStatus } from "./use-link-status.js";
46
+
47
+ // Scroll restoration
48
+ export {
49
+ ScrollRestoration,
50
+ useScrollRestoration,
51
+ type ScrollRestorationProps,
52
+ } from "./ScrollRestoration.js";
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Shared location state utilities - works in both RSC and client contexts
3
+ * No "use client" directive so it can be imported from RSC
4
+ */
5
+
6
+ /**
7
+ * Internal entry representing a state value with its unique key
8
+ */
9
+ export interface LocationStateEntry {
10
+ readonly __rsc_ls_key: string;
11
+ readonly __rsc_ls_value: unknown;
12
+ }
13
+
14
+ /**
15
+ * Type-safe location state definition
16
+ *
17
+ * Created via createLocationState(), used with Link's state prop
18
+ * and useLocationState() hook.
19
+ */
20
+ export interface LocationStateDefinition<TArgs extends unknown[], TState> {
21
+ (...args: TArgs): LocationStateEntry;
22
+ readonly __rsc_ls_key: string;
23
+ }
24
+
25
+ // Track used keys to detect duplicates in development
26
+ const usedKeys = new Set<string>();
27
+
28
+ /**
29
+ * Create a type-safe location state definition
30
+ *
31
+ * The key is auto-generated by the Vite exposeLocationStateId plugin based on
32
+ * file path and export name. No manual key required.
33
+ *
34
+ * @param key Auto-injected by Vite plugin, do not provide manually
35
+ * @returns A typed state definition for use with Link and useLocationState
36
+ *
37
+ * @example
38
+ * ```typescript
39
+ * // Define typed state (key auto-generated from file + export)
40
+ * export const ProductState = createLocationState<{ name: string; price: number }>();
41
+ *
42
+ * // Use in Link - state is captured at click time
43
+ * <Link to="/product/123" state={[ProductState({ name: product.name, price: product.price })]}>
44
+ * View Product
45
+ * </Link>
46
+ *
47
+ * // Multiple states
48
+ * <Link to="/checkout" state={[ProductState(productData), CartState(cartData)]}>
49
+ * Checkout
50
+ * </Link>
51
+ *
52
+ * // For lazy evaluation (click-time), pass a getter
53
+ * <Link to="/product" state={[ProductState(() => ({ name: product.name }))]}>
54
+ *
55
+ * // Read with type safety
56
+ * const productState = useLocationState(ProductState);
57
+ * // productState: { name: string; price: number } | undefined
58
+ * ```
59
+ */
60
+ export function createLocationState<TState>(
61
+ key?: string
62
+ ): LocationStateDefinition<[TState | (() => TState)], TState> {
63
+ if (!key && process.env.NODE_ENV !== "production") {
64
+ console.warn(
65
+ "[rsc-router] createLocationState is missing a key. " +
66
+ "Make sure the exposeLocationStateId Vite plugin is enabled and " +
67
+ "the state is exported with: export const MyState = createLocationState(...)"
68
+ );
69
+ }
70
+ const fullKey = `__rsc_ls_${key}`;
71
+
72
+ // Warn about duplicate keys in development
73
+ if (process.env.NODE_ENV !== "production" && usedKeys.has(fullKey)) {
74
+ console.warn(
75
+ `[rsc-router] Duplicate location state key "${key}". ` +
76
+ `Each createLocationState call should have a unique key.`
77
+ );
78
+ }
79
+ usedKeys.add(fullKey);
80
+
81
+ const definition = Object.assign(
82
+ (stateOrGetter: TState | (() => TState)): LocationStateEntry => ({
83
+ __rsc_ls_key: fullKey,
84
+ // Resolve getter immediately - lazy evaluation happens via Link's stateRef pattern
85
+ __rsc_ls_value:
86
+ typeof stateOrGetter === "function"
87
+ ? (stateOrGetter as () => TState)()
88
+ : stateOrGetter,
89
+ }),
90
+ { __rsc_ls_key: fullKey }
91
+ );
92
+
93
+ return definition as LocationStateDefinition<[TState | (() => TState)], TState>;
94
+ }
95
+
96
+ /**
97
+ * Check if a value is a LocationStateEntry
98
+ */
99
+ export function isLocationStateEntry(value: unknown): value is LocationStateEntry {
100
+ return (
101
+ value !== null &&
102
+ typeof value === "object" &&
103
+ "__rsc_ls_key" in value &&
104
+ "__rsc_ls_value" in value &&
105
+ typeof (value as LocationStateEntry).__rsc_ls_key === "string"
106
+ );
107
+ }
108
+
109
+ /**
110
+ * Resolve state entries into a flat object for history.state
111
+ */
112
+ export function resolveLocationStateEntries(
113
+ entries: LocationStateEntry[]
114
+ ): Record<string, unknown> {
115
+ const result: Record<string, unknown> = {};
116
+ for (const entry of entries) {
117
+ result[entry.__rsc_ls_key] = entry.__rsc_ls_value;
118
+ }
119
+ return result;
120
+ }
@@ -0,0 +1,62 @@
1
+ "use client";
2
+
3
+ import { useState, useEffect } from "react";
4
+ import type { LocationStateDefinition } from "./location-state-shared.js";
5
+
6
+ // Re-export shared utilities and types
7
+ export {
8
+ createLocationState,
9
+ isLocationStateEntry,
10
+ resolveLocationStateEntries,
11
+ type LocationStateEntry,
12
+ type LocationStateDefinition,
13
+ } from "./location-state-shared.js";
14
+
15
+ /**
16
+ * Hook to read location state from history.state
17
+ *
18
+ * Overloaded:
19
+ * - With definition: Returns typed state from the specific key
20
+ * - With type param only: Returns legacy state from history.state.state (backwards compat)
21
+ *
22
+ * @example
23
+ * ```typescript
24
+ * // Typed access with definition (recommended)
25
+ * const ProductState = createLocationState<{ name: string }>("product");
26
+ * const state = useLocationState(ProductState);
27
+ * // state: { name: string } | undefined
28
+ *
29
+ * // Legacy typed access (backwards compatible)
30
+ * const legacyState = useLocationState<{ from?: string }>();
31
+ * ```
32
+ */
33
+ export function useLocationState<TArgs extends unknown[], TState>(
34
+ definition: LocationStateDefinition<TArgs, TState>
35
+ ): TState | undefined;
36
+ export function useLocationState<T = unknown>(): T | undefined;
37
+ export function useLocationState<TArgs extends unknown[], TState>(
38
+ definition?: LocationStateDefinition<TArgs, TState>
39
+ ): TState | undefined {
40
+ const [state, setState] = useState<TState | undefined>(() => {
41
+ if (typeof window === "undefined") return undefined;
42
+ if (definition) {
43
+ return window.history.state?.[definition.__rsc_ls_key] as TState | undefined;
44
+ }
45
+ // Legacy: return history.state.state for backwards compatibility
46
+ return window.history.state?.state as TState | undefined;
47
+ });
48
+
49
+ useEffect(() => {
50
+ const handlePopstate = () => {
51
+ if (definition) {
52
+ setState(window.history.state?.[definition.__rsc_ls_key] as TState | undefined);
53
+ } else {
54
+ setState(window.history.state?.state as TState | undefined);
55
+ }
56
+ };
57
+ window.addEventListener("popstate", handlePopstate);
58
+ return () => window.removeEventListener("popstate", handlePopstate);
59
+ }, [definition]);
60
+
61
+ return state;
62
+ }
@@ -0,0 +1,32 @@
1
+ "use client";
2
+
3
+ import { createContext, createElement, type Context, type ReactNode } from "react";
4
+
5
+ /**
6
+ * Context for the current include() mount path.
7
+ *
8
+ * Each include() wraps its subtree with a MountContext.Provider
9
+ * carrying the URL prefix. Nested includes override the context,
10
+ * so useMount() returns the nearest mount path.
11
+ *
12
+ * Default value "/" means root-level (no include wrapping).
13
+ */
14
+ export const MountContext: Context<string> = createContext<string>("/");
15
+
16
+ /**
17
+ * Provider wrapper for MountContext.
18
+ *
19
+ * RSC server components cannot use MountContext.Provider directly because
20
+ * .Provider is a property on the context object, not a named export.
21
+ * Client reference proxies on the RSC server return undefined for property
22
+ * access. This wrapper is a proper "use client" export that RSC can reference.
23
+ */
24
+ export function MountContextProvider({
25
+ value,
26
+ children,
27
+ }: {
28
+ value: string;
29
+ children: ReactNode;
30
+ }): ReactNode {
31
+ return createElement(MountContext, { value }, children);
32
+ }
@@ -0,0 +1,240 @@
1
+ "use client";
2
+
3
+ import {
4
+ useContext,
5
+ useState,
6
+ useEffect,
7
+ useRef,
8
+ useOptimistic,
9
+ startTransition,
10
+ } from "react";
11
+ import { NavigationStoreContext } from "./context.js";
12
+ import type { TrackedActionState, ActionLifecycleState } from "../types.js";
13
+ import { invariant } from "../../errors.js";
14
+
15
+ /**
16
+ * Default action state (idle with no payload)
17
+ */
18
+ const DEFAULT_ACTION_STATE: TrackedActionState = {
19
+ state: "idle",
20
+ actionId: null,
21
+ payload: null,
22
+ error: null,
23
+ result: null,
24
+ };
25
+
26
+ /**
27
+ * Normalize action ID - returns the ID as-is
28
+ *
29
+ * Server actions have IDs like "hash#actionName" or "src/actions.ts#actionName".
30
+ * When using function references, we use the full ID for exact matching.
31
+ * When using strings, the event controller supports suffix matching
32
+ * (e.g., "addToCart" matches "hash#addToCart").
33
+ */
34
+ function normalizeActionId(actionId: string): string {
35
+ return actionId;
36
+ }
37
+
38
+ /**
39
+ * Extract action ID from a server action function or string.
40
+ *
41
+ * Actions passed as props from server components lose their metadata
42
+ * during RSC serialization - use a string action name instead.
43
+ */
44
+ export function getActionId(action: ServerActionFunction | string): string {
45
+ invariant(
46
+ typeof action === "function" || typeof action === "string",
47
+ `useAction: action must be a function or string, got ${typeof action}`
48
+ );
49
+ const actionId = (action as any)?.$$id;
50
+ if (actionId) {
51
+ return normalizeActionId(actionId);
52
+ }
53
+
54
+ // If action is a string, use it directly
55
+ if (typeof action === "string") {
56
+ return action;
57
+ }
58
+
59
+ // If we get here, this is likely an action passed as prop from a server component
60
+ // These lose their metadata during RSC serialization
61
+ throw new Error(
62
+ `useAction: Cannot extract action ID from function reference.
63
+
64
+ This typically happens when an action is passed as a prop from a server component.
65
+ Actions passed through RSC lose their metadata during serialization.
66
+
67
+ Solutions:
68
+ 1. Import the action directly in your client component:
69
+ import { myAction } from './actions';
70
+ const state = useAction(myAction);
71
+
72
+ 2. Use the action name as a string:
73
+ const state = useAction("myAction");
74
+
75
+ The string must match the exported function name from your "use server" file.`
76
+ );
77
+ }
78
+
79
+ /**
80
+ * Server action function type
81
+ * Server actions have a $$id property added by the RSC compiler
82
+ */
83
+ export type ServerActionFunction = ((...args: any[]) => Promise<any>) & {
84
+ $$id?: string;
85
+ };
86
+
87
+ /**
88
+ * Hook to track the lifecycle of a specific server action
89
+ *
90
+ * Unlike useNavigation which tracks global navigation state, useAction
91
+ * tracks the state of individual server action invocations.
92
+ *
93
+ * Uses the event controller for reactive state management.
94
+ * State is derived from the inflight actions tracked by the controller.
95
+ *
96
+ * Features:
97
+ * - Tracks action lifecycle: idle → loading → streaming → idle
98
+ * - Captures result/error locally (React handles cleanup)
99
+ * - If multiple actions fire, tracks only the last one
100
+ * - Supports selector pattern like useNavigation
101
+ *
102
+ * Matching behavior:
103
+ * - **Function reference**: Uses full $$id for exact matching. This is precise
104
+ * and distinguishes between actions with the same name in different files.
105
+ * - **String**: Matches by suffix (action name after #). This is convenient
106
+ * but may be ambiguous if multiple files export the same action name.
107
+ *
108
+ * @param action - Either a server action function or a string action name.
109
+ * - **Function**: Must be directly imported in the client component.
110
+ * Actions passed as props from server components will throw an error.
111
+ * - **String**: The exported function name from your "use server" file.
112
+ * Matches any action ending with "#actionName" (suffix match).
113
+ *
114
+ * @example
115
+ * ```tsx
116
+ * // Option 1: Direct import (precise matching)
117
+ * import { addToCart } from './actions';
118
+ * const actionState = useAction(addToCart);
119
+ *
120
+ * // Option 2: String-based (suffix matching)
121
+ * // Matches "hash#addToCart" or "src/actions.ts#addToCart"
122
+ * const actionState = useAction('addToCart');
123
+ *
124
+ * // With selector for specific values
125
+ * const isLoading = useAction(addToCart, state => state.state === 'loading');
126
+ * const error = useAction(addToCart, state => state.error);
127
+ * ```
128
+ *
129
+ * @note Actions passed as props from server components lose their metadata
130
+ * during RSC serialization. Use a string action name or import directly.
131
+ */
132
+ export function useAction(
133
+ action: ServerActionFunction | string
134
+ ): TrackedActionState;
135
+ export function useAction<T>(
136
+ action: ServerActionFunction | string,
137
+ selector: (state: TrackedActionState) => T
138
+ ): T;
139
+ export function useAction<T>(
140
+ action: ServerActionFunction | string,
141
+ selector?: (state: TrackedActionState) => T
142
+ ): T | TrackedActionState {
143
+ const ctx = useContext(NavigationStoreContext);
144
+ const actionId =
145
+ typeof window !== "undefined" && typeof document !== "undefined"
146
+ ? getActionId(action)
147
+ : "";
148
+
149
+ // Base state for useOptimistic
150
+ const [baseState, setBaseState] = useState<T | TrackedActionState>(() => {
151
+ if (!ctx) {
152
+ return selector ? selector(DEFAULT_ACTION_STATE) : DEFAULT_ACTION_STATE;
153
+ }
154
+ const state = ctx.eventController.getActionState(actionId);
155
+ return selector ? selector(state) : state;
156
+ });
157
+ const prevSelected = useRef(baseState);
158
+ prevSelected.current = baseState;
159
+ // useOptimistic allows immediate updates during transitions/actions
160
+ const [optimisticState, setOptimisticState] = useOptimistic<
161
+ T | TrackedActionState
162
+ >(null!);
163
+
164
+ // Memoize the selector to avoid unnecessary re-subscriptions
165
+ const selectorRef = useRef(selector);
166
+ selectorRef.current = selector;
167
+
168
+ // Subscribe to action state changes from event controller
169
+ useEffect(() => {
170
+ if (!ctx) return;
171
+
172
+ // Subscribe to action-specific updates
173
+ const unsubscribe = ctx.eventController.subscribeToAction(
174
+ actionId,
175
+ (state) => {
176
+ const selectedState = selectorRef.current
177
+ ? selectorRef.current(state)
178
+ : state;
179
+
180
+ if (!isShallowEqual(selectedState, prevSelected.current)) {
181
+ prevSelected.current = selectedState;
182
+ setBaseState(selectedState);
183
+ startTransition(() => {
184
+ setOptimisticState(selectedState);
185
+ });
186
+ }
187
+ }
188
+ );
189
+
190
+ return () => {
191
+ unsubscribe();
192
+ };
193
+ }, [actionId]);
194
+
195
+ return (optimisticState ?? baseState) as T | TrackedActionState;
196
+ }
197
+
198
+ function isShallowEqual<T, U>(selectedState: T, baseState: U): boolean {
199
+ // If references are equal, they're shallow equal
200
+ //@ts-expect-error -- TS doesn't like comparing generics
201
+ if (selectedState === baseState) {
202
+ return true;
203
+ }
204
+
205
+ // If either is null/undefined and they're not equal, they're not shallow equal
206
+ if (selectedState == null || baseState == null) {
207
+ return false;
208
+ }
209
+
210
+ // If types are different, they're not shallow equal
211
+ if (typeof selectedState !== typeof baseState) {
212
+ return false;
213
+ }
214
+
215
+ // For primitives, === comparison is sufficient (already checked above)
216
+ if (typeof selectedState !== "object") {
217
+ return false;
218
+ }
219
+
220
+ // For objects, compare keys and values shallowly
221
+ const keysA = Object.keys(selectedState as object);
222
+ const keysB = Object.keys(baseState as object);
223
+
224
+ if (keysA.length !== keysB.length) {
225
+ return false;
226
+ }
227
+
228
+ for (const key of keysA) {
229
+ if (
230
+ !Object.prototype.hasOwnProperty.call(baseState, key) ||
231
+ (selectedState as any)[key] !== (baseState as any)[key]
232
+ ) {
233
+ return false;
234
+ }
235
+ }
236
+
237
+ return true;
238
+ }
239
+
240
+ export type { TrackedActionState };
@@ -0,0 +1,56 @@
1
+ "use client";
2
+
3
+ import { useContext, useCallback } from "react";
4
+ import { NavigationStoreContext } from "./context.js";
5
+
6
+ /**
7
+ * Return type for useClientCache hook
8
+ */
9
+ export interface ClientCacheControls {
10
+ /**
11
+ * Clear the client-side navigation cache.
12
+ * Call this after data changes that happen outside of server actions
13
+ * (e.g., REST API calls, WebSocket updates, etc.)
14
+ *
15
+ * This will also broadcast to other tabs to clear their caches.
16
+ */
17
+ clear: () => void;
18
+ }
19
+
20
+ /**
21
+ * Hook to access client-side cache controls
22
+ *
23
+ * Use this when you need to manually invalidate the navigation cache
24
+ * after data changes that happen outside of server actions.
25
+ *
26
+ * Server actions automatically clear the cache, so you only need this for:
27
+ * - REST API mutations
28
+ * - WebSocket-driven updates
29
+ * - Other non-RSC data changes
30
+ *
31
+ * @example
32
+ * ```tsx
33
+ * function DataEditor() {
34
+ * const { clear } = useClientCache();
35
+ *
36
+ * async function handleSave() {
37
+ * await fetch('/api/data', { method: 'POST', body: JSON.stringify(data) });
38
+ * // Clear cache so back/forward navigation shows fresh data
39
+ * clear();
40
+ * }
41
+ *
42
+ * return <button onClick={handleSave}>Save</button>;
43
+ * }
44
+ * ```
45
+ */
46
+ export function useClientCache(): ClientCacheControls {
47
+ const ctx = useContext(NavigationStoreContext);
48
+
49
+ const clear = useCallback(() => {
50
+ if (ctx?.store) {
51
+ ctx.store.clearHistoryCache();
52
+ }
53
+ }, [ctx]);
54
+
55
+ return { clear };
56
+ }