@pylonsync/react 0.3.308 → 0.3.310

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/Link.d.ts CHANGED
@@ -6,11 +6,19 @@ declare global {
6
6
  navigate: (href: string, opts?: {
7
7
  push?: boolean;
8
8
  replace?: boolean;
9
+ seed?: unknown;
9
10
  }) => Promise<void>;
10
11
  /** Current route's dynamic params (read by useParams). A getter on the
11
12
  * runtime side, so it always reflects the latest navigation. */
12
13
  readonly params?: Record<string, string>;
14
+ /** Seed the active navigation was started with (read by useRouteSeed).
15
+ * A getter on the runtime side; null outside an optimistic nav. */
16
+ readonly seed?: unknown;
13
17
  };
18
+ /** WeakMap of <Link seed> anchor element → its seed value. Populated by
19
+ * <Link> and read by the runtime's global click handler so it can pass the
20
+ * seed to navigate() for an optimistic first paint. */
21
+ __pylonLinkSeeds?: WeakMap<Element, unknown>;
14
22
  }
15
23
  }
16
24
  export interface LinkProps extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
@@ -22,6 +30,19 @@ export interface LinkProps extends Omit<React.AnchorHTMLAttributes<HTMLAnchorEle
22
30
  * is unlikely to follow — pagination tail, etc.).
23
31
  */
24
32
  prefetch?: boolean;
33
+ /**
34
+ * Data you already have for the destination (e.g. the list item you clicked).
35
+ * When set, Pylon paints the destination page's Suspense fallback with this
36
+ * data the instant the link is clicked — before the SSR fetch resolves — so
37
+ * above-the-fold content appears immediately instead of a skeleton or a delay.
38
+ * Read it on the destination page with `useRouteSeed<T>()`.
39
+ *
40
+ * Degrades gracefully: no seed, or a route Pylon can't resolve on the client,
41
+ * simply falls back to normal navigation. Best for detail routes whose page
42
+ * keeps its `serverData` reads inside a `<Suspense>` (so the seeded fallback
43
+ * has somewhere to show).
44
+ */
45
+ seed?: unknown;
25
46
  children?: React.ReactNode;
26
47
  }
27
- export declare function Link({ href, prefetch, children, ...rest }: LinkProps): React.JSX.Element;
48
+ export declare function Link({ href, prefetch, seed, children, ...rest }: LinkProps): React.JSX.Element;
package/dist/index.d.ts CHANGED
@@ -7,7 +7,7 @@ export type { ImageProps } from "./Image";
7
7
  export { Form } from "./Form";
8
8
  export type { FormProps } from "./Form";
9
9
  export type { PageProps, PageAuth, ServerData, SsrResponse, SsrCookieOptions, Metadata, GenerateMetadata, Sitemap, SitemapEntry, Robots, RobotsRule, RouteSegmentConfig, ErrorBoundaryProps, NotFoundProps, FormFields, FormDb, FormRequest, RouteHandler, RawRouteHandler, RawResponse, } from "./ssr";
10
- export { useRouter, useSearchParams, usePathname, useParams, redirect, notFound, NotFoundError, } from "./useRouter";
10
+ export { useRouter, useSearchParams, usePathname, useParams, useRouteSeed, useRouteData, redirect, notFound, NotFoundError, } from "./useRouter";
11
11
  export type { PylonRouter } from "./useRouter";
12
12
  import { type Storage as PylonStorage } from "@pylonsync/sync";
13
13
  export { useQuery, useQueryOne, useReactiveQuery, useMutation, useInfiniteQuery, usePaginatedQuery, useEntityMutation, useAction, useQueryRaw, useQueryOneRaw, useLiveList, useLiveRow, useInsert, useUpdate, useDelete, useFn, useAggregate, useSearch, } from "./hooks";
@@ -27,6 +27,52 @@ export declare function usePathname(): string;
27
27
  * ```
28
28
  */
29
29
  export declare function useParams<T extends Record<string, string> = Record<string, string>>(): T;
30
+ /**
31
+ * The seed passed to the `<Link seed>` that started the current navigation, for
32
+ * an instant optimistic first paint. Returns the seed while the destination's
33
+ * data is still loading, then `null` once the real server render lands (and on
34
+ * hard loads / seedless navs). Use it as the page's Suspense fallback so
35
+ * above-the-fold content shows immediately instead of a skeleton:
36
+ *
37
+ * ```tsx
38
+ * export default function Page({ params, serverData }: PageProps<{ slug: string }>) {
39
+ * const seed = useRouteSeed<Product>();
40
+ * return (
41
+ * <Suspense fallback={seed ? <ProductView product={seed} pending /> : <Skeleton />}>
42
+ * <ProductDetail serverData={serverData} slug={params.slug} />
43
+ * </Suspense>
44
+ * );
45
+ * }
46
+ * ```
47
+ */
48
+ export declare function useRouteSeed<T = unknown>(): T | null;
49
+ /**
50
+ * Load a page's primary data with an optimistic first paint from `<Link seed>`.
51
+ * This is the flash-free way to consume a seed — prefer it over reading
52
+ * `useRouteSeed()` into a Suspense fallback (a fallback→content transition
53
+ * remounts, which flickers).
54
+ *
55
+ * - Hard load / SSR (no seed): suspends on `loader()`, so the server renders +
56
+ * streams the real content and the client hydrates from the pre-fulfilled
57
+ * serverData cache. Wrap the calling component in `<Suspense>`.
58
+ * - Optimistic client nav (a `<Link seed>` was clicked): returns the seed
59
+ * immediately as real content — NO Suspense fallback — then swaps to the
60
+ * resolved `loader()` value IN PLACE (same component instance), so there is no
61
+ * remount between the seed and the authoritative row. No flash.
62
+ *
63
+ * `deps` drive the background reload — pass the values `loader` closes over
64
+ * (e.g. `[serverData, slug]`). Key the component by its dynamic route param if
65
+ * it serves both seeded and hard-loaded requests, so each navigation mounts a
66
+ * fresh instance in the right mode.
67
+ *
68
+ * ```tsx
69
+ * function Detail({ serverData, slug }: { serverData: ServerData; slug: string }) {
70
+ * const product = useRouteData(() => loadProduct(serverData, slug), [serverData, slug]);
71
+ * return product ? <ProductView product={product} /> : <NotFound />;
72
+ * }
73
+ * ```
74
+ */
75
+ export declare function useRouteData<T>(loader: () => Promise<T> | T, deps: readonly unknown[]): T | null;
30
76
  /**
31
77
  * Client-side redirect — replaces the current history entry with `href`.
32
78
  * Drop-in for Next's `redirect` when called from a client component
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.3.308",
6
+ "version": "0.3.310",
7
7
  "type": "module",
8
8
  "main": "./src/index.ts",
9
9
  "types": "./dist/index.d.ts",
@@ -13,8 +13,8 @@
13
13
  "prepack": "bun run build"
14
14
  },
15
15
  "dependencies": {
16
- "@pylonsync/sdk": "0.3.308",
17
- "@pylonsync/sync": "0.3.308"
16
+ "@pylonsync/sdk": "0.3.310",
17
+ "@pylonsync/sync": "0.3.310"
18
18
  },
19
19
  "peerDependencies": {
20
20
  "react": ">=19.0.0"
package/src/Link.tsx CHANGED
@@ -32,12 +32,19 @@ declare global {
32
32
  prefetch: (href: string) => Promise<void>;
33
33
  navigate: (
34
34
  href: string,
35
- opts?: { push?: boolean; replace?: boolean },
35
+ opts?: { push?: boolean; replace?: boolean; seed?: unknown },
36
36
  ) => Promise<void>;
37
37
  /** Current route's dynamic params (read by useParams). A getter on the
38
38
  * runtime side, so it always reflects the latest navigation. */
39
39
  readonly params?: Record<string, string>;
40
+ /** Seed the active navigation was started with (read by useRouteSeed).
41
+ * A getter on the runtime side; null outside an optimistic nav. */
42
+ readonly seed?: unknown;
40
43
  };
44
+ /** WeakMap of <Link seed> anchor element → its seed value. Populated by
45
+ * <Link> and read by the runtime's global click handler so it can pass the
46
+ * seed to navigate() for an optimistic first paint. */
47
+ __pylonLinkSeeds?: WeakMap<Element, unknown>;
41
48
  }
42
49
  }
43
50
 
@@ -51,17 +58,47 @@ export interface LinkProps
51
58
  * is unlikely to follow — pagination tail, etc.).
52
59
  */
53
60
  prefetch?: boolean;
61
+ /**
62
+ * Data you already have for the destination (e.g. the list item you clicked).
63
+ * When set, Pylon paints the destination page's Suspense fallback with this
64
+ * data the instant the link is clicked — before the SSR fetch resolves — so
65
+ * above-the-fold content appears immediately instead of a skeleton or a delay.
66
+ * Read it on the destination page with `useRouteSeed<T>()`.
67
+ *
68
+ * Degrades gracefully: no seed, or a route Pylon can't resolve on the client,
69
+ * simply falls back to normal navigation. Best for detail routes whose page
70
+ * keeps its `serverData` reads inside a `<Suspense>` (so the seeded fallback
71
+ * has somewhere to show).
72
+ */
73
+ seed?: unknown;
54
74
  children?: React.ReactNode;
55
75
  }
56
76
 
57
77
  export function Link({
58
78
  href,
59
79
  prefetch = true,
80
+ seed,
60
81
  children,
61
82
  ...rest
62
83
  }: LinkProps) {
63
84
  const ref = useRef<HTMLAnchorElement>(null);
64
85
 
86
+ // Register the seed keyed by this anchor element so the runtime's global
87
+ // click handler can find it and hand it to navigate(). A WeakMap keeps it
88
+ // GC-safe (no leak when the link unmounts) and off the DOM node itself.
89
+ useEffect(() => {
90
+ if (typeof window === "undefined") return;
91
+ const el = ref.current;
92
+ if (!el || seed === undefined) return;
93
+ const reg =
94
+ window.__pylonLinkSeeds ||
95
+ (window.__pylonLinkSeeds = new WeakMap<Element, unknown>());
96
+ reg.set(el, seed);
97
+ return () => {
98
+ reg.delete(el);
99
+ };
100
+ }, [seed]);
101
+
65
102
  useEffect(() => {
66
103
  if (!prefetch || !ref.current) return;
67
104
  if (typeof window === "undefined") return;
package/src/index.ts CHANGED
@@ -41,6 +41,8 @@ export {
41
41
  useSearchParams,
42
42
  usePathname,
43
43
  useParams,
44
+ useRouteSeed,
45
+ useRouteData,
44
46
  redirect,
45
47
  notFound,
46
48
  NotFoundError,
package/src/useRouter.ts CHANGED
@@ -12,7 +12,7 @@
12
12
  // `searchParams` PROPS the runtime already hands every page (see PageProps);
13
13
  // the hooks exist for deep children that need to react to client navigation
14
14
  // without prop-drilling.
15
- import { useMemo, useSyncExternalStore } from "react";
15
+ import { use, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
16
16
 
17
17
  // `Window.__pylon` is globally augmented in ./Link (same package, ambient).
18
18
 
@@ -114,6 +114,99 @@ export function useParams<
114
114
  ) as T;
115
115
  }
116
116
 
117
+ // The seed for the active navigation, stashed on `window.__pylon.seed` by the
118
+ // runtime when a <Link seed> is clicked. A stable reference for the whole nav
119
+ // (set once at nav start), which useSyncExternalStore requires. Null otherwise.
120
+ function seedClientSnapshot(): unknown {
121
+ return (typeof window !== "undefined" && window.__pylon?.seed) || null;
122
+ }
123
+ function seedServerSnapshot(): unknown {
124
+ return null;
125
+ }
126
+
127
+ /**
128
+ * The seed passed to the `<Link seed>` that started the current navigation, for
129
+ * an instant optimistic first paint. Returns the seed while the destination's
130
+ * data is still loading, then `null` once the real server render lands (and on
131
+ * hard loads / seedless navs). Use it as the page's Suspense fallback so
132
+ * above-the-fold content shows immediately instead of a skeleton:
133
+ *
134
+ * ```tsx
135
+ * export default function Page({ params, serverData }: PageProps<{ slug: string }>) {
136
+ * const seed = useRouteSeed<Product>();
137
+ * return (
138
+ * <Suspense fallback={seed ? <ProductView product={seed} pending /> : <Skeleton />}>
139
+ * <ProductDetail serverData={serverData} slug={params.slug} />
140
+ * </Suspense>
141
+ * );
142
+ * }
143
+ * ```
144
+ */
145
+ export function useRouteSeed<T = unknown>(): T | null {
146
+ return useSyncExternalStore(
147
+ subscribe,
148
+ seedClientSnapshot,
149
+ seedServerSnapshot,
150
+ ) as T | null;
151
+ }
152
+
153
+ /**
154
+ * Load a page's primary data with an optimistic first paint from `<Link seed>`.
155
+ * This is the flash-free way to consume a seed — prefer it over reading
156
+ * `useRouteSeed()` into a Suspense fallback (a fallback→content transition
157
+ * remounts, which flickers).
158
+ *
159
+ * - Hard load / SSR (no seed): suspends on `loader()`, so the server renders +
160
+ * streams the real content and the client hydrates from the pre-fulfilled
161
+ * serverData cache. Wrap the calling component in `<Suspense>`.
162
+ * - Optimistic client nav (a `<Link seed>` was clicked): returns the seed
163
+ * immediately as real content — NO Suspense fallback — then swaps to the
164
+ * resolved `loader()` value IN PLACE (same component instance), so there is no
165
+ * remount between the seed and the authoritative row. No flash.
166
+ *
167
+ * `deps` drive the background reload — pass the values `loader` closes over
168
+ * (e.g. `[serverData, slug]`). Key the component by its dynamic route param if
169
+ * it serves both seeded and hard-loaded requests, so each navigation mounts a
170
+ * fresh instance in the right mode.
171
+ *
172
+ * ```tsx
173
+ * function Detail({ serverData, slug }: { serverData: ServerData; slug: string }) {
174
+ * const product = useRouteData(() => loadProduct(serverData, slug), [serverData, slug]);
175
+ * return product ? <ProductView product={product} /> : <NotFound />;
176
+ * }
177
+ * ```
178
+ */
179
+ export function useRouteData<T>(
180
+ loader: () => Promise<T> | T,
181
+ deps: readonly unknown[],
182
+ ): T | null {
183
+ const seed = useRouteSeed<T>();
184
+ // Fix the mode at first render so the hook calls below stay stable even if the
185
+ // seed changes on a later same-instance nav (rules of hooks). `use()` may be
186
+ // called conditionally, but useState/useEffect may not — hence the ref.
187
+ const optimistic = useRef(seed != null).current;
188
+ const [data, setData] = useState<T | null>(optimistic ? seed : null);
189
+ useEffect(() => {
190
+ if (!optimistic) return;
191
+ let live = true;
192
+ Promise.resolve(loader()).then((v) => {
193
+ if (live && v != null) setData(v as T);
194
+ });
195
+ return () => {
196
+ live = false;
197
+ };
198
+ // loader is intentionally excluded; `deps` are the reload trigger.
199
+ // eslint-disable-next-line react-hooks/exhaustive-deps
200
+ }, deps);
201
+ if (!optimistic) {
202
+ // SSR + hard load + non-seeded nav: suspend so the server streams the real
203
+ // content and the client hydrates it synchronously from the pre-fulfilled
204
+ // serverData cache (matches the server HTML — no mismatch).
205
+ return use(Promise.resolve(loader())) as T;
206
+ }
207
+ return data ?? seed;
208
+ }
209
+
117
210
  /**
118
211
  * Client-side redirect — replaces the current history entry with `href`.
119
212
  * Drop-in for Next's `redirect` when called from a client component