@pylonsync/react 0.4.10 → 0.4.12
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 +5 -1
- package/package.json +3 -3
- package/src/Link.tsx +38 -17
- package/src/useRouter.test.ts +12 -3
package/dist/Link.d.ts
CHANGED
|
@@ -2,7 +2,11 @@ import React from "react";
|
|
|
2
2
|
declare global {
|
|
3
3
|
interface Window {
|
|
4
4
|
__pylon?: {
|
|
5
|
-
prefetch: (href: string
|
|
5
|
+
prefetch: (href: string, opts?: {
|
|
6
|
+
/** Also fetch the page payload. Costs a server render, so only on an
|
|
7
|
+
* intent signal: hover, touch, or focus. */
|
|
8
|
+
document?: boolean;
|
|
9
|
+
}) => Promise<void>;
|
|
6
10
|
navigate: (href: string, opts?: {
|
|
7
11
|
push?: boolean;
|
|
8
12
|
replace?: boolean;
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "0.4.
|
|
6
|
+
"version": "0.4.12",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"main": "./src/index.ts",
|
|
9
9
|
"types": "./dist/index.d.ts",
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
"prepack": "bun run build"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@pylonsync/sdk": "0.4.
|
|
18
|
-
"@pylonsync/sync": "0.4.
|
|
17
|
+
"@pylonsync/sdk": "0.4.12",
|
|
18
|
+
"@pylonsync/sync": "0.4.12"
|
|
19
19
|
},
|
|
20
20
|
"peerDependencies": {
|
|
21
21
|
"react": ">=19.0.0"
|
package/src/Link.tsx
CHANGED
|
@@ -8,12 +8,14 @@
|
|
|
8
8
|
// route entry, and re-renders the React root in place. Layouts
|
|
9
9
|
// that match across the two routes survive reconciliation, so
|
|
10
10
|
// their state persists.
|
|
11
|
-
// - Prefetches
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
11
|
+
// - Prefetches in two stages, because the two halves cost very
|
|
12
|
+
// different things. On viewport entry (IntersectionObserver) the
|
|
13
|
+
// runtime modulepreloads the destination's client chunks — static,
|
|
14
|
+
// immutable, cacheable. On hover, touch, or focus it also fetches
|
|
15
|
+
// the page itself, which costs a server render, so that waits for a
|
|
16
|
+
// real signal of intent rather than firing for a screenful of links.
|
|
17
|
+
// Focus is included so a keyboard user tabbing to a link gets the
|
|
18
|
+
// same warm path a mouse user gets.
|
|
17
19
|
// - Modifier keys (cmd/ctrl/shift/alt) or middle-click fall
|
|
18
20
|
// through to the browser's default behavior (open in new tab,
|
|
19
21
|
// etc.) — matches Next.js semantics.
|
|
@@ -29,7 +31,14 @@ import React, { useEffect, useRef } from "react";
|
|
|
29
31
|
declare global {
|
|
30
32
|
interface Window {
|
|
31
33
|
__pylon?: {
|
|
32
|
-
prefetch: (
|
|
34
|
+
prefetch: (
|
|
35
|
+
href: string,
|
|
36
|
+
opts?: {
|
|
37
|
+
/** Also fetch the page payload. Costs a server render, so only on an
|
|
38
|
+
* intent signal: hover, touch, or focus. */
|
|
39
|
+
document?: boolean;
|
|
40
|
+
},
|
|
41
|
+
) => Promise<void>;
|
|
33
42
|
navigate: (
|
|
34
43
|
href: string,
|
|
35
44
|
opts?: { push?: boolean; replace?: boolean; seed?: unknown },
|
|
@@ -115,12 +124,19 @@ export function Link({
|
|
|
115
124
|
}
|
|
116
125
|
|
|
117
126
|
const el = ref.current;
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
127
|
+
// Chunks warm once, on sight. The page payload warms on intent — and may
|
|
128
|
+
// re-warm on a later hover, since the runtime expires payloads that have
|
|
129
|
+
// gone stale.
|
|
130
|
+
let warmedChunks = false;
|
|
131
|
+
const warmChunks = () => {
|
|
132
|
+
if (warmedChunks) return;
|
|
133
|
+
warmedChunks = true;
|
|
122
134
|
window.__pylon?.prefetch(href);
|
|
123
135
|
};
|
|
136
|
+
const warmPage = () => {
|
|
137
|
+
warmedChunks = true;
|
|
138
|
+
window.__pylon?.prefetch(href, { document: true });
|
|
139
|
+
};
|
|
124
140
|
|
|
125
141
|
let io: IntersectionObserver | null = null;
|
|
126
142
|
if (typeof IntersectionObserver !== "undefined") {
|
|
@@ -128,7 +144,7 @@ export function Link({
|
|
|
128
144
|
(entries) => {
|
|
129
145
|
for (const e of entries) {
|
|
130
146
|
if (e.isIntersecting) {
|
|
131
|
-
|
|
147
|
+
warmChunks();
|
|
132
148
|
io?.disconnect();
|
|
133
149
|
}
|
|
134
150
|
}
|
|
@@ -138,14 +154,19 @@ export function Link({
|
|
|
138
154
|
io.observe(el);
|
|
139
155
|
}
|
|
140
156
|
|
|
141
|
-
|
|
142
|
-
el.addEventListener("
|
|
143
|
-
|
|
157
|
+
el.addEventListener("mouseenter", warmPage);
|
|
158
|
+
el.addEventListener("touchstart", warmPage, { passive: true });
|
|
159
|
+
// Focus is the keyboard's version of hover, and a stronger signal: a
|
|
160
|
+
// cursor slides across links it isn't aiming at, but nobody tabs onto one
|
|
161
|
+
// idly. Without it, tabbing to a link and pressing Enter took the cold
|
|
162
|
+
// path while a mouse user got the warm one — the wrong way round.
|
|
163
|
+
el.addEventListener("focus", warmPage);
|
|
144
164
|
|
|
145
165
|
return () => {
|
|
146
166
|
io?.disconnect();
|
|
147
|
-
el.removeEventListener("mouseenter",
|
|
148
|
-
el.removeEventListener("touchstart",
|
|
167
|
+
el.removeEventListener("mouseenter", warmPage);
|
|
168
|
+
el.removeEventListener("touchstart", warmPage);
|
|
169
|
+
el.removeEventListener("focus", warmPage);
|
|
149
170
|
};
|
|
150
171
|
}, [href, prefetch]);
|
|
151
172
|
|
package/src/useRouter.test.ts
CHANGED
|
@@ -48,10 +48,16 @@ describe("notFound() — branded for the SSR not-found boundary", () => {
|
|
|
48
48
|
describe("redirect() — client-side replace navigation", () => {
|
|
49
49
|
let calls: Array<{ href: string; opts?: unknown }>;
|
|
50
50
|
const hadWindow = "window" in globalThis;
|
|
51
|
+
// The suite preloads happy-dom, so a real window usually EXISTS here. Put it
|
|
52
|
+
// back rather than leaving the stub in place: bun isolates globals per test
|
|
53
|
+
// file today, so nothing observable depends on this, but a test that
|
|
54
|
+
// replaces a global and never restores it is one isolation change away from
|
|
55
|
+
// breaking every file that mounts.
|
|
56
|
+
const originalWindow = (globalThis as any).window;
|
|
51
57
|
|
|
52
58
|
beforeEach(() => {
|
|
53
59
|
calls = [];
|
|
54
|
-
//
|
|
60
|
+
// Stand up a minimal window the module can see, without assuming a DOM.
|
|
55
61
|
(globalThis as any).window = {
|
|
56
62
|
__pylon: {
|
|
57
63
|
prefetch: async () => {},
|
|
@@ -63,8 +69,11 @@ describe("redirect() — client-side replace navigation", () => {
|
|
|
63
69
|
});
|
|
64
70
|
|
|
65
71
|
afterEach(() => {
|
|
66
|
-
if (hadWindow)
|
|
67
|
-
|
|
72
|
+
if (hadWindow) {
|
|
73
|
+
(globalThis as any).window = originalWindow;
|
|
74
|
+
} else {
|
|
75
|
+
delete (globalThis as any).window;
|
|
76
|
+
}
|
|
68
77
|
});
|
|
69
78
|
|
|
70
79
|
test("delegates to __pylon.navigate with replace:true (no history push)", () => {
|