@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 CHANGED
@@ -2,7 +2,11 @@ import React from "react";
2
2
  declare global {
3
3
  interface Window {
4
4
  __pylon?: {
5
- prefetch: (href: string) => Promise<void>;
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.10",
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.10",
18
- "@pylonsync/sync": "0.4.10"
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 on viewport entry (IntersectionObserver) by default.
12
- // The runtime injects `<link rel=prefetch>` for the HTML and
13
- // `<link rel=modulepreload>` for the shared chunks the user
14
- // will need.
15
- // - Hover/touch escalation: if the link isn't in the viewport
16
- // long enough, hovering still triggers prefetch.
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: (href: string) => Promise<void>;
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
- let prefetched = false;
119
- const doPrefetch = () => {
120
- if (prefetched) return;
121
- prefetched = true;
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
- doPrefetch();
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
- const onHover = () => doPrefetch();
142
- el.addEventListener("mouseenter", onHover, { once: true });
143
- el.addEventListener("touchstart", onHover, { once: true, passive: true });
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", onHover);
148
- el.removeEventListener("touchstart", onHover);
167
+ el.removeEventListener("mouseenter", warmPage);
168
+ el.removeEventListener("touchstart", warmPage);
169
+ el.removeEventListener("focus", warmPage);
149
170
  };
150
171
  }, [href, prefetch]);
151
172
 
@@ -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
- // bun:test has no DOM — stand up a minimal window the module can see.
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) return;
67
- delete (globalThis as any).window;
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)", () => {