@pylonsync/react 0.3.261 → 0.3.263
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 +3 -3
- package/src/Link.tsx +3 -0
- package/src/index.ts +9 -1
- package/src/useRouter.test.ts +82 -0
- package/src/useRouter.ts +66 -0
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.3.
|
|
6
|
+
"version": "0.3.263",
|
|
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.
|
|
16
|
-
"@pylonsync/sync": "0.3.
|
|
15
|
+
"@pylonsync/sdk": "0.3.263",
|
|
16
|
+
"@pylonsync/sync": "0.3.263"
|
|
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 {
|
|
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 {
|
|
@@ -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. */
|