@pylonsync/react 0.3.245 → 0.3.247

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/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.3.245",
6
+ "version": "0.3.247",
7
7
  "type": "module",
8
8
  "main": "src/index.ts",
9
9
  "types": "src/index.ts",
@@ -12,8 +12,8 @@
12
12
  "check": "tsc -p tsconfig.json --noEmit"
13
13
  },
14
14
  "dependencies": {
15
- "@pylonsync/sdk": "0.3.245",
16
- "@pylonsync/sync": "0.3.245"
15
+ "@pylonsync/sdk": "0.3.247",
16
+ "@pylonsync/sync": "0.3.247"
17
17
  },
18
18
  "peerDependencies": {
19
19
  "react": ">=19.0.0"
package/src/Link.tsx CHANGED
@@ -30,7 +30,10 @@ declare global {
30
30
  interface Window {
31
31
  __pylon?: {
32
32
  prefetch: (href: string) => Promise<void>;
33
- navigate: (href: string, opts?: { push?: boolean }) => Promise<void>;
33
+ navigate: (
34
+ href: string,
35
+ opts?: { push?: boolean; replace?: boolean },
36
+ ) => Promise<void>;
34
37
  };
35
38
  }
36
39
  }
package/src/index.ts CHANGED
@@ -8,6 +8,22 @@ export type { LinkProps } from "./Link";
8
8
  export { Image } from "./Image";
9
9
  export type { ImageProps } from "./Image";
10
10
 
11
+ // SSR page-author types — the contract every `app/**/page.tsx` is handed
12
+ // in props, plus `metadata` / `generateMetadata`. Type-only.
13
+ export type {
14
+ PageProps,
15
+ PageAuth,
16
+ ServerData,
17
+ SsrResponse,
18
+ SsrCookieOptions,
19
+ Metadata,
20
+ GenerateMetadata,
21
+ } from "./ssr";
22
+
23
+ // Client navigation hooks for SSR pages (Next-style).
24
+ export { useRouter, useSearchParams, usePathname } from "./useRouter";
25
+ export type { PylonRouter } from "./useRouter";
26
+
11
27
  import {
12
28
  defaultStorage,
13
29
  pylonFetch,
package/src/ssr.ts ADDED
@@ -0,0 +1,207 @@
1
+ // SSR page-author types.
2
+ //
3
+ // These describe the contract the Pylon SSR runtime hands every
4
+ // `app/**/page.tsx` (and `layout.tsx`) component in props, plus the
5
+ // `metadata` / `generateMetadata` exports. They are TYPE-ONLY — no runtime
6
+ // — so a page author writes:
7
+ //
8
+ // import type { PageProps, Metadata } from "@pylonsync/react";
9
+ //
10
+ // export const metadata: Metadata = { title: "Blog" };
11
+ // export default function Page({ params, serverData, response }: PageProps) { ... }
12
+ //
13
+ // The source of truth for the shape is the runtime in
14
+ // `@pylonsync/functions` (`ssr-runtime.ts`); these mirror it. Keep them in
15
+ // sync when the runtime props change.
16
+
17
+ /**
18
+ * The resolved Pylon auth context for the request that rendered the page.
19
+ * Safe to read in the component body — it is serialized into the hydration
20
+ * payload, so the server render and the client hydration see the same
21
+ * values (no hydration mismatch).
22
+ */
23
+ export interface PageAuth {
24
+ /** The signed-in user's id, or null for an anonymous request. */
25
+ user_id: string | null;
26
+ /** True when the session is an admin session (PYLON_ADMIN_EMAILS). */
27
+ is_admin: boolean;
28
+ /** The active tenant/org id, or null. */
29
+ tenant_id: string | null;
30
+ /** Role slugs granted to the session. */
31
+ roles: string[];
32
+ }
33
+
34
+ /** Options for `response.setCookie`. Defaults: HttpOnly + SameSite=Lax. */
35
+ export interface SsrCookieOptions {
36
+ path?: string;
37
+ domain?: string;
38
+ maxAge?: number;
39
+ expires?: Date | string;
40
+ /** Defaults to true (secure default). Pass false for a client-readable cookie. */
41
+ httpOnly?: boolean;
42
+ secure?: boolean;
43
+ sameSite?: "Strict" | "Lax" | "None";
44
+ }
45
+
46
+ /**
47
+ * The per-render `response` controller. Pylon already has a backend for
48
+ * data + mutations, so SSR's job is just the HTTP response envelope:
49
+ * status, redirects, 404, and the occasional Set-Cookie.
50
+ *
51
+ * IMPORTANT — call these during the SYNCHRONOUS shell render (the component
52
+ * body, before any `await` / Suspense boundary). The HTTP head is committed
53
+ * when the shell is ready; status/headers/cookies set from a suspended
54
+ * subtree that streams in later are lost, and a `redirect()` / `notFound()`
55
+ * thrown below a Suspense boundary is swallowed by React's error handling
56
+ * rather than turned into a 3xx / 404.
57
+ */
58
+ export interface SsrResponse {
59
+ /** Set the HTTP status (100–599). Default 200. */
60
+ setStatus(code: number): void;
61
+ /** Set a response header (name must be a token; value CR/LF/NUL-free). */
62
+ setHeader(name: string, value: string): void;
63
+ /** Append a Set-Cookie. Defaults: HttpOnly + SameSite=Lax. */
64
+ setCookie(name: string, value: string, opts?: SsrCookieOptions): void;
65
+ /** Throw to send a 3xx (default 307) + Location, no body. Shell-render only. */
66
+ redirect(url: string, status?: number): never;
67
+ /**
68
+ * Throw to send a 404. Renders the nearest `not-found.tsx` (walking up
69
+ * from the page's directory, wrapped in the route's layout chain), or a
70
+ * minimal framework body if none is defined. Shell-render only.
71
+ */
72
+ notFound(): never;
73
+ }
74
+
75
+ /**
76
+ * Read-only database handle a page reaches during render via React 19
77
+ * `use()` + `<Suspense>`. Reads run through the same store + policy gate as
78
+ * a query function's `ctx.db`; writes are rejected. Resolved values are
79
+ * cached and replayed into the hydration payload, so the client does not
80
+ * re-fetch on hydration.
81
+ *
82
+ * ```tsx
83
+ * export default function Page({ serverData }: PageProps) {
84
+ * const posts = use(serverData.list<Post>("Post"));
85
+ * return <ul>{posts.map((p) => <li key={p.id}>{p.title}</li>)}</ul>;
86
+ * }
87
+ * ```
88
+ */
89
+ export interface ServerData {
90
+ /** Get a single row by id. Resolves to null if not found. */
91
+ get<T = Record<string, unknown>>(entity: string, id: string): Promise<T | null>;
92
+ /** List all (policy-visible) rows for an entity. */
93
+ list<T = Record<string, unknown>>(entity: string): Promise<T[]>;
94
+ /** Look up a row by a field value (e.g. email). Null if not found. */
95
+ lookup<T = Record<string, unknown>>(
96
+ entity: string,
97
+ field: string,
98
+ value: string,
99
+ ): Promise<T | null>;
100
+ /** Query with filters ($gt, $lt, $in, $like, $order, $limit, …). */
101
+ query<T = Record<string, unknown>>(
102
+ entity: string,
103
+ filter: Record<string, unknown>,
104
+ ): Promise<T[]>;
105
+ /** Execute a graph query with nested relation includes. */
106
+ queryGraph<T = Record<string, unknown>>(
107
+ query: Record<string, unknown>,
108
+ ): Promise<T>;
109
+ /** Cursor-paginated list. Pass `cursor` from a previous page's result. */
110
+ paginate<T = Record<string, unknown>>(
111
+ entity: string,
112
+ opts: { numItems: number; cursor?: string | null },
113
+ ): Promise<{ rows?: T[]; page?: T[]; nextCursor?: string | null }>;
114
+ /** Faceted full-text search against an entity with a `search:` config. */
115
+ search<T = Record<string, unknown>>(
116
+ entity: string,
117
+ query: Record<string, unknown>,
118
+ ): Promise<{
119
+ hits: T[];
120
+ facetCounts?: Record<string, Record<string, number>>;
121
+ total: number;
122
+ tookMs?: number;
123
+ }>;
124
+ }
125
+
126
+ /**
127
+ * Props every `page.tsx` / `layout.tsx` receives. Generic over the dynamic
128
+ * route params and the parsed query string, so a route like
129
+ * `app/blog/[slug]/page.tsx` can type them:
130
+ *
131
+ * ```tsx
132
+ * export default function Post({ params }: PageProps<{ slug: string }>) {
133
+ * return <article>{params.slug}</article>;
134
+ * }
135
+ * ```
136
+ *
137
+ * Note: the incoming request's headers + cookies are intentionally NOT on
138
+ * this type. They are available only during the server render and are
139
+ * stripped from the hydration payload (a session cookie must never reach
140
+ * client JS), so reading them in the component body would hydrate-mismatch.
141
+ * Read request-derived data through `serverData` or a server function.
142
+ */
143
+ export interface PageProps<
144
+ TParams extends Record<string, string> = Record<string, string>,
145
+ TSearchParams extends Record<string, string> = Record<string, string>,
146
+ > {
147
+ /** The incoming URL path (e.g. `/blog/hello-world`). */
148
+ url: string;
149
+ /** Dynamic-segment matches keyed by name (e.g. `{ slug: "hello-world" }`). */
150
+ params: TParams;
151
+ /** Parsed query string (e.g. `?start=10` → `{ start: "10" }`). */
152
+ searchParams: TSearchParams;
153
+ /** The resolved auth context for the request. */
154
+ auth: PageAuth;
155
+ /** The HTTP response controller (status / headers / cookies / redirect). */
156
+ response: SsrResponse;
157
+ /** Read-only database handle for in-render data (use with `use()`). */
158
+ serverData: ServerData;
159
+ }
160
+
161
+ /**
162
+ * Page SEO metadata. Export `const metadata` (static) or
163
+ * `async function generateMetadata(props)` (dynamic, e.g. param-derived
164
+ * titles) from a `page.tsx` / `layout.tsx`. React 19 hoists the resulting
165
+ * `<title>` / `<meta>` / `<link>` into `<head>`.
166
+ */
167
+ export interface Metadata {
168
+ title?: string;
169
+ description?: string;
170
+ keywords?: string | string[];
171
+ canonical?: string;
172
+ robots?: string;
173
+ openGraph?: {
174
+ title?: string;
175
+ description?: string;
176
+ image?: string;
177
+ imageSecureUrl?: string;
178
+ imageType?: string;
179
+ imageWidth?: number;
180
+ imageHeight?: number;
181
+ imageAlt?: string;
182
+ url?: string;
183
+ type?: string;
184
+ };
185
+ twitter?: {
186
+ card?: string;
187
+ title?: string;
188
+ description?: string;
189
+ image?: string;
190
+ };
191
+ icons?: {
192
+ icon?: { url: string; type?: string; sizes?: string };
193
+ apple?: { url: string; type?: string; sizes?: string };
194
+ };
195
+ }
196
+
197
+ /**
198
+ * Signature of a dynamic `generateMetadata` export. Receives the same props
199
+ * as the page; return (or resolve to) the page's `Metadata`. Awaited before
200
+ * the first byte, so keep it to cheap derivations.
201
+ */
202
+ export type GenerateMetadata<
203
+ TParams extends Record<string, string> = Record<string, string>,
204
+ TSearchParams extends Record<string, string> = Record<string, string>,
205
+ > = (
206
+ props: PageProps<TParams, TSearchParams>,
207
+ ) => Metadata | Promise<Metadata>;
@@ -0,0 +1,136 @@
1
+ // Client navigation hooks for Pylon SSR pages — useRouter / useSearchParams
2
+ // / usePathname. They drive (and read) the same client runtime that <Link>
3
+ // uses: `window.__pylon.navigate` for programmatic nav, and the
4
+ // `pylon:navigation` event (dispatched by the runtime after every nav) +
5
+ // native `popstate` for reactivity.
6
+ //
7
+ // SSR note: useSearchParams / usePathname are CLIENT-reactive. During the
8
+ // server render (and the matching first hydration pass) they return defaults
9
+ // (empty params / "/") so there's never a hydration mismatch — React's
10
+ // useSyncExternalStore uses the server snapshot for both, then re-renders
11
+ // with the live value. For SSR-time access to the URL, read the `url` /
12
+ // `searchParams` PROPS the runtime already hands every page (see PageProps);
13
+ // the hooks exist for deep children that need to react to client navigation
14
+ // without prop-drilling.
15
+ import { useMemo, useSyncExternalStore } from "react";
16
+
17
+ // `Window.__pylon` is globally augmented in ./Link (same package, ambient).
18
+
19
+ function subscribe(onChange: () => void): () => void {
20
+ if (typeof window === "undefined") return () => {};
21
+ window.addEventListener("popstate", onChange);
22
+ window.addEventListener("pylon:navigation", onChange);
23
+ return () => {
24
+ window.removeEventListener("popstate", onChange);
25
+ window.removeEventListener("pylon:navigation", onChange);
26
+ };
27
+ }
28
+
29
+ // useSyncExternalStore compares snapshots with Object.is, so the getSnapshot
30
+ // must return a STABLE reference until the underlying value changes — a fresh
31
+ // URLSearchParams every call would loop forever. Cache by the raw search
32
+ // string.
33
+ let cachedSearch: string | null = null;
34
+ let cachedParams = new URLSearchParams();
35
+ function searchClientSnapshot(): URLSearchParams {
36
+ const s = typeof window !== "undefined" ? window.location.search : "";
37
+ if (s !== cachedSearch) {
38
+ cachedSearch = s;
39
+ cachedParams = new URLSearchParams(s);
40
+ }
41
+ return cachedParams;
42
+ }
43
+ const EMPTY_PARAMS = new URLSearchParams();
44
+ function searchServerSnapshot(): URLSearchParams {
45
+ return EMPTY_PARAMS;
46
+ }
47
+
48
+ /**
49
+ * The current query string as a reactive `URLSearchParams`. Re-renders on
50
+ * client navigation. Returns empty params during SSR / first hydration —
51
+ * use the `searchParams` page prop for server-side values.
52
+ *
53
+ * ```tsx
54
+ * const params = useSearchParams();
55
+ * const tab = params.get("tab") ?? "overview";
56
+ * ```
57
+ */
58
+ export function useSearchParams(): URLSearchParams {
59
+ return useSyncExternalStore(
60
+ subscribe,
61
+ searchClientSnapshot,
62
+ searchServerSnapshot,
63
+ );
64
+ }
65
+
66
+ function pathClientSnapshot(): string {
67
+ return typeof window !== "undefined" ? window.location.pathname : "/";
68
+ }
69
+ function pathServerSnapshot(): string {
70
+ return "/";
71
+ }
72
+
73
+ /**
74
+ * The current pathname (no query/hash), reactive to client navigation.
75
+ * Returns "/" during SSR / first hydration — use the `url` page prop for
76
+ * server-side values.
77
+ */
78
+ export function usePathname(): string {
79
+ return useSyncExternalStore(subscribe, pathClientSnapshot, pathServerSnapshot);
80
+ }
81
+
82
+ /** Imperative navigation handle (Next-style `useRouter`). */
83
+ export interface PylonRouter {
84
+ /** Navigate to `href`, pushing a new history entry. */
85
+ push(href: string): void;
86
+ /** Navigate to `href`, replacing the current history entry. */
87
+ replace(href: string): void;
88
+ /** Go back one history entry. */
89
+ back(): void;
90
+ /** Go forward one history entry. */
91
+ forward(): void;
92
+ /** Re-fetch + re-render the current route (fresh server data). */
93
+ refresh(): void;
94
+ /** Warm the SSR HTML + chunks for `href` ahead of a navigation. */
95
+ prefetch(href: string): void;
96
+ }
97
+
98
+ /**
99
+ * Programmatic client navigation. Methods are no-ops before hydration /
100
+ * during SSR (there's no client runtime yet), so they're safe to call from
101
+ * effects and event handlers.
102
+ *
103
+ * ```tsx
104
+ * const router = useRouter();
105
+ * <button onClick={() => router.push("/dashboard")}>Go</button>
106
+ * ```
107
+ */
108
+ export function useRouter(): PylonRouter {
109
+ return useMemo<PylonRouter>(
110
+ () => ({
111
+ push(href) {
112
+ void window.__pylon?.navigate(href, { push: true });
113
+ },
114
+ replace(href) {
115
+ void window.__pylon?.navigate(href, { replace: true });
116
+ },
117
+ back() {
118
+ if (typeof window !== "undefined") window.history.back();
119
+ },
120
+ forward() {
121
+ if (typeof window !== "undefined") window.history.forward();
122
+ },
123
+ refresh() {
124
+ if (typeof window === "undefined") return;
125
+ void window.__pylon?.navigate(
126
+ window.location.pathname + window.location.search,
127
+ { replace: true },
128
+ );
129
+ },
130
+ prefetch(href) {
131
+ void window.__pylon?.prefetch(href);
132
+ },
133
+ }),
134
+ [],
135
+ );
136
+ }