@pylonsync/react 0.3.259 → 0.3.262

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.259",
6
+ "version": "0.3.262",
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.259",
16
- "@pylonsync/sync": "0.3.259"
15
+ "@pylonsync/sdk": "0.3.262",
16
+ "@pylonsync/sync": "0.3.262"
17
17
  },
18
18
  "peerDependencies": {
19
19
  "react": ">=19.0.0"
package/src/Link.tsx CHANGED
@@ -34,6 +34,9 @@ declare global {
34
34
  href: string,
35
35
  opts?: { push?: boolean; replace?: boolean },
36
36
  ) => Promise<void>;
37
+ /** Current route's dynamic params (read by useParams). A getter on the
38
+ * runtime side, so it always reflects the latest navigation. */
39
+ readonly params?: Record<string, string>;
37
40
  };
38
41
  }
39
42
  }
package/src/index.ts CHANGED
@@ -30,7 +30,15 @@ export type {
30
30
  } from "./ssr";
31
31
 
32
32
  // Client navigation hooks for SSR pages (Next-style).
33
- export { useRouter, useSearchParams, usePathname } from "./useRouter";
33
+ export {
34
+ useRouter,
35
+ useSearchParams,
36
+ usePathname,
37
+ useParams,
38
+ redirect,
39
+ notFound,
40
+ NotFoundError,
41
+ } from "./useRouter";
34
42
  export type { PylonRouter } from "./useRouter";
35
43
 
36
44
  import {
@@ -104,7 +112,7 @@ export { useSyncStatus } from "./useSyncStatus";
104
112
  export type { SyncConnectionStatus } from "./useSyncStatus";
105
113
 
106
114
  // One-liner API
107
- export { db, init } from "./db";
115
+ export { db, init, getSync } from "./db";
108
116
 
109
117
  // Typed client (consumes generated AppSchema)
110
118
  export { createTypedDb } from "./typed";
@@ -0,0 +1,82 @@
1
+ // Contract coverage for the Next-compatible router primitives that back the
2
+ // Pylon Cloud frontend migration off Next.js: `notFound()`, `redirect()`, and
3
+ // the params snapshot read by `useParams`.
4
+ //
5
+ // We test the observable contracts directly instead of mounting React (the
6
+ // package ships no renderer dep — same approach as useRoom.test.ts), and we
7
+ // stub a minimal `globalThis.window` since bun:test has no DOM:
8
+ // - `notFound()` throws a branded error the SSR runtime recognizes by digest
9
+ // (the cross-package handshake in ssr-runtime's `asRouteControl`).
10
+ // - `redirect()` delegates to the client runtime's `navigate(_, {replace})`.
11
+
12
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
13
+
14
+ import { NotFoundError, notFound, redirect } from "./useRouter";
15
+
16
+ describe("notFound() — branded for the SSR not-found boundary", () => {
17
+ test("throws a NotFoundError", () => {
18
+ expect(() => notFound()).toThrow(NotFoundError);
19
+ });
20
+
21
+ test("the thrown error carries the exact digest the runtime keys on", () => {
22
+ // This string is the cross-package contract: ssr-runtime.ts's
23
+ // `asRouteControl` matches `err.digest === "PYLON_NOT_FOUND"`. If this
24
+ // literal drifts on either side, server-render notFound() silently 500s
25
+ // instead of 404ing. Pin it on both sides so a drift breaks a test.
26
+ let caught: unknown;
27
+ try {
28
+ notFound();
29
+ } catch (e) {
30
+ caught = e;
31
+ }
32
+ expect(caught).toBeInstanceOf(NotFoundError);
33
+ expect((caught as NotFoundError).digest).toBe("PYLON_NOT_FOUND");
34
+ });
35
+
36
+ test("notFound() never returns (control always throws)", () => {
37
+ let reached = false;
38
+ try {
39
+ notFound();
40
+ reached = true;
41
+ } catch {
42
+ /* expected */
43
+ }
44
+ expect(reached).toBe(false);
45
+ });
46
+ });
47
+
48
+ describe("redirect() — client-side replace navigation", () => {
49
+ let calls: Array<{ href: string; opts?: unknown }>;
50
+ const hadWindow = "window" in globalThis;
51
+
52
+ beforeEach(() => {
53
+ calls = [];
54
+ // bun:test has no DOM — stand up a minimal window the module can see.
55
+ (globalThis as any).window = {
56
+ __pylon: {
57
+ prefetch: async () => {},
58
+ navigate: async (href: string, opts?: unknown) => {
59
+ calls.push({ href, opts });
60
+ },
61
+ },
62
+ };
63
+ });
64
+
65
+ afterEach(() => {
66
+ if (hadWindow) return;
67
+ delete (globalThis as any).window;
68
+ });
69
+
70
+ test("delegates to __pylon.navigate with replace:true (no history push)", () => {
71
+ redirect("/login");
72
+ expect(calls).toHaveLength(1);
73
+ expect(calls[0].href).toBe("/login");
74
+ expect(calls[0].opts).toEqual({ replace: true });
75
+ });
76
+
77
+ test("is a no-op (doesn't throw) when the client runtime isn't ready", () => {
78
+ (globalThis as any).window.__pylon = undefined;
79
+ expect(() => redirect("/login")).not.toThrow();
80
+ expect(calls).toHaveLength(0);
81
+ });
82
+ });
package/src/useRouter.ts CHANGED
@@ -79,6 +79,72 @@ export function usePathname(): string {
79
79
  return useSyncExternalStore(subscribe, pathClientSnapshot, pathServerSnapshot);
80
80
  }
81
81
 
82
+ // The current route's dynamic params, stashed on `window.__pylon.params` by the
83
+ // SSR client runtime at hydration + on every nav. A stable object reference
84
+ // between navs (the runtime mints a fresh one per route), which
85
+ // useSyncExternalStore requires.
86
+ const EMPTY_OBJ: Record<string, string> = {};
87
+ function paramsClientSnapshot(): Record<string, string> {
88
+ return (
89
+ (typeof window !== "undefined" && window.__pylon?.params) || EMPTY_OBJ
90
+ );
91
+ }
92
+ function paramsServerSnapshot(): Record<string, string> {
93
+ return EMPTY_OBJ;
94
+ }
95
+
96
+ /**
97
+ * The current route's dynamic params — e.g. `/dashboard/[projectId]` →
98
+ * `{ projectId: "p_1" }`. Reactive to client navigation, so a deep child gets
99
+ * the new params after a `<Link>` click without prop-drilling. Returns `{}`
100
+ * during SSR / first hydration — use the `params` page prop for server-side
101
+ * values. Drop-in for Next's `useParams`.
102
+ *
103
+ * ```tsx
104
+ * const { projectId } = useParams<{ projectId: string }>();
105
+ * ```
106
+ */
107
+ export function useParams<
108
+ T extends Record<string, string> = Record<string, string>,
109
+ >(): T {
110
+ return useSyncExternalStore(
111
+ subscribe,
112
+ paramsClientSnapshot,
113
+ paramsServerSnapshot,
114
+ ) as T;
115
+ }
116
+
117
+ /**
118
+ * Client-side redirect — replaces the current history entry with `href`.
119
+ * Drop-in for Next's `redirect` when called from a client component
120
+ * (effect/handler). For a redirect decided during a server render, use the
121
+ * `response.redirect()` API on the page's `PageProps` instead.
122
+ */
123
+ export function redirect(href: string): void {
124
+ if (typeof window !== "undefined") {
125
+ void window.__pylon?.navigate(href, { replace: true });
126
+ }
127
+ }
128
+
129
+ /** Error thrown by {@link notFound}; the SSR not-found boundary renders it. */
130
+ export class NotFoundError extends Error {
131
+ readonly digest = "PYLON_NOT_FOUND";
132
+ constructor() {
133
+ super("PYLON_NOT_FOUND");
134
+ this.name = "NotFoundError";
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Render the nearest `not-found.tsx` boundary from a client component by
140
+ * throwing — drop-in for Next's `notFound`. For a 404 decided during a server
141
+ * render, prefer `response.notFound()` on the page's `PageProps` so the
142
+ * response carries a real 404 status.
143
+ */
144
+ export function notFound(): never {
145
+ throw new NotFoundError();
146
+ }
147
+
82
148
  /** Imperative navigation handle (Next-style `useRouter`). */
83
149
  export interface PylonRouter {
84
150
  /** Navigate to `href`, pushing a new history entry. */