@rsc-kit/core 0.20.6 → 0.20.8

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.
Files changed (65) hide show
  1. package/dist/cache.d.ts +11 -0
  2. package/dist/cache.js +13 -0
  3. package/dist/cache.js.map +1 -1
  4. package/dist/clientEntries.js +4 -1
  5. package/dist/clientEntries.js.map +1 -1
  6. package/dist/files.d.ts +38 -0
  7. package/dist/files.js +109 -0
  8. package/dist/files.js.map +1 -1
  9. package/dist/host.d.ts +11 -2
  10. package/dist/host.js +37 -6
  11. package/dist/host.js.map +1 -1
  12. package/dist/js/ActivityRouter.d.ts +11 -0
  13. package/dist/js/ActivityRouter.js +24 -4
  14. package/dist/js/ActivityRouter.js.map +1 -1
  15. package/dist/js/Form.js +24 -2
  16. package/dist/js/Form.js.map +1 -1
  17. package/dist/js/Link.d.ts +1 -1
  18. package/dist/js/Link.js +25 -9
  19. package/dist/js/Link.js.map +1 -1
  20. package/dist/js/PageTransition.d.ts +27 -0
  21. package/dist/js/PageTransition.js +57 -0
  22. package/dist/js/PageTransition.js.map +1 -0
  23. package/dist/js/PathnameProvider.d.ts +15 -2
  24. package/dist/js/PathnameProvider.js +17 -2
  25. package/dist/js/PathnameProvider.js.map +1 -1
  26. package/dist/js/SegmentBoundary.js +27 -3
  27. package/dist/js/SegmentBoundary.js.map +1 -1
  28. package/dist/js/activityMarkers.d.ts +7 -0
  29. package/dist/js/activityMarkers.js +18 -0
  30. package/dist/js/activityMarkers.js.map +1 -0
  31. package/dist/js/createViteRscApp.js +122 -76
  32. package/dist/js/createViteRscApp.js.map +1 -1
  33. package/dist/js/earlyClicks.d.ts +3 -1
  34. package/dist/js/earlyClicks.js +28 -5
  35. package/dist/js/earlyClicks.js.map +1 -1
  36. package/dist/js/errors.d.ts +2 -0
  37. package/dist/js/errors.js +11 -0
  38. package/dist/js/errors.js.map +1 -1
  39. package/dist/js/imagePreload.d.ts +37 -0
  40. package/dist/js/imagePreload.js +116 -0
  41. package/dist/js/imagePreload.js.map +1 -0
  42. package/dist/js/navigate.d.ts +47 -1
  43. package/dist/js/navigate.js +249 -10
  44. package/dist/js/navigate.js.map +1 -1
  45. package/dist/js/perf.d.ts +25 -0
  46. package/dist/js/perf.js +57 -0
  47. package/dist/js/perf.js.map +1 -0
  48. package/dist/js/router.d.ts +10 -1
  49. package/dist/js/router.js +9 -2
  50. package/dist/js/router.js.map +1 -1
  51. package/dist/js/segmentStore.d.ts +52 -0
  52. package/dist/js/segmentStore.js +123 -2
  53. package/dist/js/segmentStore.js.map +1 -1
  54. package/dist/js/staleAssets.d.ts +10 -0
  55. package/dist/js/staleAssets.js +23 -2
  56. package/dist/js/staleAssets.js.map +1 -1
  57. package/dist/js/viewportPrefetch.d.ts +19 -10
  58. package/dist/js/viewportPrefetch.js +50 -15
  59. package/dist/js/viewportPrefetch.js.map +1 -1
  60. package/dist/shellHead.d.ts +22 -0
  61. package/dist/shellHead.js +43 -0
  62. package/dist/shellHead.js.map +1 -0
  63. package/dist/vite.js +250 -55
  64. package/dist/vite.js.map +1 -1
  65. package/package.json +5 -1
@@ -45,6 +45,14 @@ interface Entry {
45
45
  * however long ago that was.
46
46
  */
47
47
  at: number;
48
+ /**
49
+ * Rendered before the click, hidden, on the strength of a touch or a
50
+ * settled hover - see prerenderSegment. Outside the retention window: it
51
+ * is a guess, and a guess must not evict a page the visitor was on. One
52
+ * per depth; the next guess replaces it, and a navigation to anything
53
+ * else drops it.
54
+ */
55
+ speculative?: boolean;
48
56
  }
49
57
  /**
50
58
  * Immutable: useSyncExternalStore compares snapshots by identity, so a new
@@ -69,6 +77,32 @@ export declare function getSegmentState(depth: number): DepthState | null;
69
77
  * render the previous page inside the new one.
70
78
  */
71
79
  export declare function setSegment(depth: number, key: string, tree: Tree): void;
80
+ /**
81
+ * Render a page hidden at `depth`, before any navigation to it.
82
+ *
83
+ * The click then finds the work done: setSegment with the same tree is a
84
+ * bail-out for React - same element, same props - and the Activity flips
85
+ * from hidden to visible. What was 87 ms of rendering on a phone, after
86
+ * the tap, is paid before the finger lifts, at idle priority, yielding to
87
+ * the scroll. Never changes what is showing, never counts against
88
+ * retention, and a page already held needs nothing.
89
+ */
90
+ export declare function prerenderSegment(depth: number, key: string, tree: Tree): void;
91
+ /** Whether a page is rendered hidden at `depth`, ahead of a navigation to it. */
92
+ export declare function isPrerendered(depth: number, key: string): boolean;
93
+ /**
94
+ * Give the page on screen at `depth` a new tree, under the key it has.
95
+ *
96
+ * For revalidate("all"): the whole document rendered again, in place. The
97
+ * root hands its layout the new tree, the layout hands its boundary new
98
+ * children, and a boundary that holds state would otherwise keep showing
99
+ * the store's tree and drop the new one on the floor - or, cleared first,
100
+ * re-key its Activity to the current url and remount everything under it.
101
+ * Neither. The entry that is showing takes the new tree and keeps its key,
102
+ * so React reconciles the page in place, and the boundary below gets its
103
+ * new children the same way.
104
+ */
105
+ export declare function replaceActive(depth: number, tree: Tree): void;
72
106
  /**
73
107
  * Record the children the server rendered, so the page you arrived on can be
74
108
  * returned to later. Never changes what is showing.
@@ -87,6 +121,24 @@ export declare function seedSegment(depth: number, key: string, tree: Tree): voi
87
121
  * deeper boundary, so they delegate to whatever it is showing. One that does
88
122
  * hold the key is switched to it, since that is a real change at its level.
89
123
  */
124
+ /**
125
+ * Whether a link to `key` would be answered by revealing a held page.
126
+ *
127
+ * The question restoreSegments answers, asked without acting on it: a
128
+ * prefetch of a page the boundaries still hold is a request for nothing -
129
+ * a navigation would reveal it. The page just left is the usual case, one
130
+ * wasted payload per navigation.
131
+ */
132
+ export declare function isHeld(key: string, maxAge?: number): boolean;
133
+ /**
134
+ * Drop every page held behind the one on screen.
135
+ *
136
+ * After a mutation: a page kept for the back button holds the data from
137
+ * before it, and revealing it would show a row that is gone, a name that
138
+ * changed. The page on screen stays - it was re-rendered by the action, or
139
+ * is about to be.
140
+ */
141
+ export declare function dropHidden(): void;
90
142
  /**
91
143
  * Reveal a page still being held, if it is worth revealing.
92
144
  *
@@ -37,6 +37,8 @@
37
37
  /** Pages kept alive per boundary. Four covers ordinary back-and-forth. */
38
38
  export const RETENTION = 4;
39
39
  const depths = new Map();
40
+ /** When a mutation last made everything held before it wrong. */
41
+ let invalidatedAt = 0;
40
42
  const listeners = new Map();
41
43
  function notify(depth) {
42
44
  for (const listener of listeners.get(depth) ?? [])
@@ -63,7 +65,7 @@ export function getSegmentState(depth) {
63
65
  function retain(entries, order, activeKey) {
64
66
  const kept = order.slice(-RETENTION);
65
67
  return {
66
- entries: entries.filter((entry) => kept.includes(entry.key)),
68
+ entries: entries.filter((entry) => kept.includes(entry.key) || entry.speculative),
67
69
  order: kept,
68
70
  activeKey,
69
71
  };
@@ -84,6 +86,11 @@ function put(depth, key, tree) {
84
86
  * render the previous page inside the new one.
85
87
  */
86
88
  export function setSegment(depth, key, tree) {
89
+ // A guess about another page was wrong; the one about this page, if there
90
+ // was one, is replaced by put() below - with the same tree, when the
91
+ // prerender and the navigation read the same decoded payload, which is
92
+ // what makes the update a reveal rather than a render.
93
+ dropSpeculative(depth, key);
87
94
  put(depth, key, tree);
88
95
  const stale = [...depths.keys()].filter((d) => d > depth);
89
96
  for (const d of stale)
@@ -92,6 +99,74 @@ export function setSegment(depth, key, tree) {
92
99
  for (const d of stale)
93
100
  notify(d);
94
101
  }
102
+ function dropSpeculative(depth, except) {
103
+ const state = depths.get(depth);
104
+ if (!state?.entries.some((entry) => entry.speculative && entry.key !== except))
105
+ return;
106
+ depths.set(depth, {
107
+ ...state,
108
+ entries: state.entries.filter((entry) => !entry.speculative || entry.key === except),
109
+ });
110
+ }
111
+ /**
112
+ * Render a page hidden at `depth`, before any navigation to it.
113
+ *
114
+ * The click then finds the work done: setSegment with the same tree is a
115
+ * bail-out for React - same element, same props - and the Activity flips
116
+ * from hidden to visible. What was 87 ms of rendering on a phone, after
117
+ * the tap, is paid before the finger lifts, at idle priority, yielding to
118
+ * the scroll. Never changes what is showing, never counts against
119
+ * retention, and a page already held needs nothing.
120
+ */
121
+ export function prerenderSegment(depth, key, tree) {
122
+ const state = depths.get(depth);
123
+ // Nothing at this depth yet: the boundary is still showing the server's
124
+ // children, and it has no page key to keep them under. A guess would take
125
+ // the store over with no active entry to show. seedSegment runs on mount,
126
+ // so this is the gap between hydration and that effect; the click will
127
+ // render.
128
+ if (!state)
129
+ return;
130
+ if (state.entries.some((entry) => entry.key === key))
131
+ return;
132
+ depths.set(depth, {
133
+ ...state,
134
+ entries: [
135
+ ...state.entries.filter((entry) => !entry.speculative),
136
+ { key, tree, at: Date.now(), speculative: true },
137
+ ],
138
+ });
139
+ notify(depth);
140
+ }
141
+ /** Whether a page is rendered hidden at `depth`, ahead of a navigation to it. */
142
+ export function isPrerendered(depth, key) {
143
+ return depths.get(depth)?.entries.some((entry) => entry.key === key && entry.speculative) ?? false;
144
+ }
145
+ /**
146
+ * Give the page on screen at `depth` a new tree, under the key it has.
147
+ *
148
+ * For revalidate("all"): the whole document rendered again, in place. The
149
+ * root hands its layout the new tree, the layout hands its boundary new
150
+ * children, and a boundary that holds state would otherwise keep showing
151
+ * the store's tree and drop the new one on the floor - or, cleared first,
152
+ * re-key its Activity to the current url and remount everything under it.
153
+ * Neither. The entry that is showing takes the new tree and keeps its key,
154
+ * so React reconciles the page in place, and the boundary below gets its
155
+ * new children the same way.
156
+ */
157
+ export function replaceActive(depth, tree) {
158
+ const state = depths.get(depth);
159
+ if (!state)
160
+ return;
161
+ const active = state.entries.find((entry) => entry.key === state.activeKey);
162
+ if (!active || active.tree === tree)
163
+ return;
164
+ depths.set(depth, {
165
+ ...state,
166
+ entries: state.entries.map((entry) => (entry === active ? { ...entry, tree, at: Date.now() } : entry)),
167
+ });
168
+ notify(depth);
169
+ }
95
170
  /**
96
171
  * Record the children the server rendered, so the page you arrived on can be
97
172
  * returned to later. Never changes what is showing.
@@ -123,6 +198,47 @@ export function seedSegment(depth, key, tree) {
123
198
  * deeper boundary, so they delegate to whatever it is showing. One that does
124
199
  * hold the key is switched to it, since that is a real change at its level.
125
200
  */
201
+ /**
202
+ * Whether a link to `key` would be answered by revealing a held page.
203
+ *
204
+ * The question restoreSegments answers, asked without acting on it: a
205
+ * prefetch of a page the boundaries still hold is a request for nothing -
206
+ * a navigation would reveal it. The page just left is the usual case, one
207
+ * wasted payload per navigation.
208
+ */
209
+ export function isHeld(key, maxAge) {
210
+ const ages = [...depths.values()].flatMap((state) => state.entries.filter((entry) => entry.key === key && !entry.speculative && entry.at >= invalidatedAt).map((entry) => entry.at));
211
+ if (ages.length === 0)
212
+ return false;
213
+ if (maxAge === undefined)
214
+ return true;
215
+ return Date.now() - Math.min(...ages) < maxAge;
216
+ }
217
+ /**
218
+ * Drop every page held behind the one on screen.
219
+ *
220
+ * After a mutation: a page kept for the back button holds the data from
221
+ * before it, and revealing it would show a row that is gone, a name that
222
+ * changed. The page on screen stays - it was re-rendered by the action, or
223
+ * is about to be.
224
+ */
225
+ export function dropHidden() {
226
+ // And the ones that stay - the active entry at each depth - are from
227
+ // before it too. A layout's entry is keyed by the page it was seeded
228
+ // with, and revealing that key later shows the page inside it as it was
229
+ // then. Nothing seeded before this moment is revealed again.
230
+ invalidatedAt = Date.now();
231
+ for (const [depth, state] of depths) {
232
+ if (state.entries.length <= 1 && !state.entries.some((entry) => entry.speculative))
233
+ continue;
234
+ depths.set(depth, {
235
+ entries: state.entries.filter((entry) => entry.key === state.activeKey),
236
+ order: state.order.filter((key) => key === state.activeKey),
237
+ activeKey: state.activeKey,
238
+ });
239
+ notify(depth);
240
+ }
241
+ }
126
242
  /**
127
243
  * Reveal a page still being held, if it is worth revealing.
128
244
  *
@@ -134,7 +250,11 @@ export function seedSegment(depth, key, tree) {
134
250
  * just on, with the form you were filling in still filled in.
135
251
  */
136
252
  export function restoreSegments(key, maxAge) {
137
- const holding = [...depths.keys()].filter((d) => depths.get(d).entries.some((entry) => entry.key === key));
253
+ // A guess is not a held page: revealing it would be showing prefetched
254
+ // data as the page the visitor was on. The navigation takes the
255
+ // prerendered tree through setSegment instead, where it is a reveal too.
256
+ // Nor is a page from before a mutation - see dropHidden.
257
+ const holding = [...depths.keys()].filter((d) => depths.get(d).entries.some((entry) => entry.key === key && !entry.speculative && entry.at >= invalidatedAt));
138
258
  if (holding.length === 0)
139
259
  return false;
140
260
  if (maxAge !== undefined) {
@@ -155,6 +275,7 @@ export function restoreSegments(key, maxAge) {
155
275
  notify(d);
156
276
  }
157
277
  for (const d of holding) {
278
+ dropSpeculative(d);
158
279
  const state = depths.get(d);
159
280
  depths.set(d, retain(state.entries, [...state.order.filter((k) => k !== key), key], key));
160
281
  notify(d);
@@ -1 +1 @@
1
- {"version":3,"file":"segmentStore.js","sourceRoot":"","sources":["../../src/js/segmentStore.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AA4BH,0EAA0E;AAC1E,MAAM,CAAC,MAAM,SAAS,GAAG,CAAC,CAAC;AAE3B,MAAM,MAAM,GAAG,IAAI,GAAG,EAAsB,CAAC;AAC7C,MAAM,SAAS,GAAG,IAAI,GAAG,EAAyB,CAAC;AAEnD,SAAS,MAAM,CAAC,KAAa;IAC3B,KAAK,MAAM,QAAQ,IAAI,SAAS,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,EAAE;QAAE,QAAQ,EAAE,CAAC;AAChE,CAAC;AAED,MAAM,UAAU,kBAAkB,CAChC,KAAa,EACb,QAAkB;IAElB,IAAI,GAAG,GAAG,SAAS,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;IAE/B,IAAI,CAAC,GAAG,EAAE,CAAC;QACT,GAAG,GAAG,IAAI,GAAG,EAAE,CAAC;QAChB,SAAS,CAAC,GAAG,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC;IAC5B,CAAC;IAED,GAAG,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;IAElB,OAAO,GAAG,EAAE;QACV,GAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;QACtB,IAAI,GAAI,CAAC,IAAI,KAAK,CAAC;YAAE,SAAS,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IAC/C,CAAC,CAAC;AACJ,CAAC;AAED,uFAAuF;AACvF,MAAM,UAAU,eAAe,CAAC,KAAa;IAC3C,OAAO,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,IAAI,IAAI,CAAC;AACnC,CAAC;AAED,uDAAuD;AACvD,SAAS,MAAM,CACb,OAAyB,EACzB,KAAwB,EACxB,SAAiB;IAEjB,MAAM,IAAI,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,SAAS,CAAC,CAAC;IAErC,OAAO;QACL,OAAO,EAAE,OAAO,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;QAC5D,KAAK,EAAE,IAAI;QACX,SAAS;KACV,CAAC;AACJ,CAAC;AAED,SAAS,GAAG,CAAC,KAAa,EAAE,GAAW,EAAE,IAAU;IACjD,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;IAChC,MAAM,OAAO,GAAG;QACd,GAAG,CAAC,KAAK,EAAE,OAAO,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,KAAK,GAAG,CAAC;QAC9D,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE;KAC9B,CAAC;IACF,MAAM,KAAK,GAAG,CAAC,GAAG,CAAC,KAAK,EAAE,KAAK,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,GAAG,CAAC,EAAE,GAAG,CAAC,CAAC;IAEtE,MAAM,CAAC,GAAG,CAAC,KAAK,EAAE,MAAM,CAAC,OAAO,EAAE,KAAK,EAAE,GAAG,CAAC,CAAC,CAAC;AACjD,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,UAAU,CAAC,KAAa,EAAE,GAAW,EAAE,IAAU;IAC/D,GAAG,CAAC,KAAK,EAAE,GAAG,EAAE,IAAI,CAAC,CAAC;IAEtB,MAAM,KAAK,GAAG,CAAC,GAAG,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,KAAK,CAAC,CAAC;IAC1D,KAAK,MAAM,CAAC,IAAI,KAAK;QAAE,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;IAExC,MAAM,CAAC,KAAK,CAAC,CAAC;IACd,KAAK,MAAM,CAAC,IAAI,KAAK;QAAE,MAAM,CAAC,CAAC,CAAC,CAAC;AACnC,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,WAAW,CAAC,KAAa,EAAE,GAAW,EAAE,IAAU;IAChE,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;IAEhC,IAAI,KAAK,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,KAAK,GAAG,CAAC;QAAE,OAAO;IAE9D,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,GAAG,CAAC,KAAK,EAAE,GAAG,EAAE,IAAI,CAAC,CAAC;QACtB,MAAM,CAAC,KAAK,CAAC,CAAC;QAEd,OAAO;IACT,CAAC;IAED,0EAA0E;IAC1E,yDAAyD;IACzD,MAAM,CAAC,GAAG,CACR,KAAK,EACL,MAAM,CACJ,CAAC,GAAG,KAAK,CAAC,OAAO,EAAE,EAAE,GAAG,EAAE,IAAI,EAAE,EAAE,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC,EACjD,CAAC,GAAG,EAAE,GAAG,KAAK,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,EAC9C,KAAK,CAAC,SAAS,CAChB,CACF,CAAC;IAEF,MAAM,CAAC,KAAK,CAAC,CAAC;AAChB,CAAC;AAED;;;;;;;;;;;;GAYG;AACH;;;;;;;;;GASG;AACH,MAAM,UAAU,eAAe,CAAC,GAAW,EAAE,MAAe;IAC1D,MAAM,OAAO,GAAG,CAAC,GAAG,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAC9C,MAAM,CAAC,GAAG,CAAC,CAAC,CAAE,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,KAAK,GAAG,CAAC,CAC1D,CAAC;IAEF,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC;IAEvC,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;QACzB,MAAM,IAAI,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE,CACjC,MAAM;aACH,GAAG,CAAC,CAAC,CAAE;aACP,OAAO,CAAC,MAAM,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,KAAK,GAAG,CAAC;aAC5C,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,EAAE,CAAC,CAC5B,CAAC;QAEF,wEAAwE;QACxE,6CAA6C;QAC7C,0EAA0E;QAC1E,yBAAyB;QACzB,IAAI,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC,IAAI,MAAM;YAAE,OAAO,KAAK,CAAC;IAC7D,CAAC;IAED,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,OAAO,CAAC,CAAC;IAEpC,KAAK,MAAM,CAAC,IAAI,CAAC,GAAG,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,MAAM,CAAC,EAAE,CAAC;QAC7D,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;QACjB,MAAM,CAAC,CAAC,CAAC,CAAC;IACZ,CAAC;IAED,KAAK,MAAM,CAAC,IAAI,OAAO,EAAE,CAAC;QACxB,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC,CAAE,CAAC;QAE7B,MAAM,CAAC,GAAG,CACR,CAAC,EACD,MAAM,CACJ,KAAK,CAAC,OAAO,EACb,CAAC,GAAG,KAAK,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,KAAK,GAAG,CAAC,EAAE,GAAG,CAAC,EAC9C,GAAG,CACJ,CACF,CAAC;QACF,MAAM,CAAC,CAAC,CAAC,CAAC;IACZ,CAAC;IAED,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,aAAa;IAC3B,MAAM,GAAG,GAAG,CAAC,GAAG,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC;IAC/B,MAAM,CAAC,KAAK,EAAE,CAAC;IACf,KAAK,MAAM,KAAK,IAAI,GAAG;QAAE,MAAM,CAAC,KAAK,CAAC,CAAC;AACzC,CAAC;AAED;;;;;;;;;GASG;AACH,IAAI,SAAS,GAAG,KAAK,CAAC;AAEtB,MAAM,UAAU,cAAc;IAC5B,SAAS,GAAG,IAAI,CAAC;AACnB,CAAC;AAED,MAAM,UAAU,aAAa;IAC3B,OAAO,SAAS,CAAC;AACnB,CAAC","sourcesContent":["/**\n * What each segment boundary is showing, and what it is keeping alive behind it.\n *\n * A navigation replaces one segment. Keeping the previous one mounted — hidden,\n * not unmounted — is what lets going back restore it with its client state:\n * the half-typed form, the open disclosure, the scrolled list. Unmounting\n * throws all of that away, which is what replacing the root used to do.\n *\n * Entries are keyed by page (the URL, or its intercept variant), so a boundary\n * can hold several and reveal one. Empty is meaningful: a boundary with nothing\n * stored renders the children the server gave it.\n *\n * A store rather than state, and not a preference — three things rule state out.\n * A boundary is inserted between every layout level, so there are several and a\n * navigation targets one by depth; they are separated by server components, so\n * no setter can be threaded down to them, because a function does not cross\n * that boundary; and navigate.ts is a plain module with no component instance\n * to call one on. Addressing a component you hold no reference to is what an\n * external store is for.\n *\n * Underneath it is the wire protocol. A partial navigation sends only the\n * segment that changed — see the X-RSC-Segments headers — so there is nothing\n * for a root to re-render with even if it held the state. Next.js keeps its\n * router in useState and derives every segment from it; that is the same trade\n * in the other direction.\n *\n * What it costs: React pins an external store's updates to synchronous\n * priority, because a store cannot be safely time-sliced. Synchronous is never\n * a transition, and anything that only runs for one — React's <ViewTransition>\n * among them — never ran for a navigation.\n *\n * Which is why SegmentBoundary does not read this with useSyncExternalStore\n * any more. The store still does the addressing, which is the part only it can\n * do; the boundary copies into state, so the render is a transition. See the\n * view transitions guide.\n */\n\ntype Tree = unknown;\ntype Listener = () => void;\n\ninterface Entry {\n key: string;\n tree: Tree;\n /**\n * When this tree arrived, so a link can decide whether it is still worth\n * revealing. The back button never asks — it means \"the page I was on\",\n * however long ago that was.\n */\n at: number;\n}\n\n/**\n * Immutable: useSyncExternalStore compares snapshots by identity, so a new\n * object per read reads as \"changed every render\" and loops forever. Every\n * mutation replaces this wholesale; nothing edits one in place.\n */\ninterface DepthState {\n readonly entries: readonly Entry[];\n readonly activeKey: string;\n /** Most recently shown last; eviction takes from the front. */\n readonly order: readonly string[];\n}\n\n/** Pages kept alive per boundary. Four covers ordinary back-and-forth. */\nexport const RETENTION = 4;\n\nconst depths = new Map<number, DepthState>();\nconst listeners = new Map<number, Set<Listener>>();\n\nfunction notify(depth: number): void {\n for (const listener of listeners.get(depth) ?? []) listener();\n}\n\nexport function subscribeToSegment(\n depth: number,\n listener: Listener,\n): () => void {\n let set = listeners.get(depth);\n\n if (!set) {\n set = new Set();\n listeners.set(depth, set);\n }\n\n set.add(listener);\n\n return () => {\n set!.delete(listener);\n if (set!.size === 0) listeners.delete(depth);\n };\n}\n\n/** Everything a boundary at this depth needs to render, or null for \"use children\". */\nexport function getSegmentState(depth: number): DepthState | null {\n return depths.get(depth) ?? null;\n}\n\n/** Apply the retention window to a candidate state. */\nfunction retain(\n entries: readonly Entry[],\n order: readonly string[],\n activeKey: string,\n): DepthState {\n const kept = order.slice(-RETENTION);\n\n return {\n entries: entries.filter((entry) => kept.includes(entry.key)),\n order: kept,\n activeKey,\n };\n}\n\nfunction put(depth: number, key: string, tree: Tree): void {\n const state = depths.get(depth);\n const entries = [\n ...(state?.entries ?? []).filter((entry) => entry.key !== key),\n { key, tree, at: Date.now() },\n ];\n const order = [...(state?.order ?? []).filter((k) => k !== key), key];\n\n depths.set(depth, retain(entries, order, key));\n}\n\n/**\n * Show `tree` at `depth` for `key`, retaining what was there.\n *\n * Deeper segments belonged to the page being replaced; leaving them would\n * render the previous page inside the new one.\n */\nexport function setSegment(depth: number, key: string, tree: Tree): void {\n put(depth, key, tree);\n\n const stale = [...depths.keys()].filter((d) => d > depth);\n for (const d of stale) depths.delete(d);\n\n notify(depth);\n for (const d of stale) notify(d);\n}\n\n/**\n * Record the children the server rendered, so the page you arrived on can be\n * returned to later. Never changes what is showing.\n */\nexport function seedSegment(depth: number, key: string, tree: Tree): void {\n const state = depths.get(depth);\n\n if (state?.entries.some((entry) => entry.key === key)) return;\n\n if (!state) {\n put(depth, key, tree);\n notify(depth);\n\n return;\n }\n\n // Older than whatever is showing, so it goes to the front of the eviction\n // order — and crucially does not become the active page.\n depths.set(\n depth,\n retain(\n [...state.entries, { key, tree, at: Date.now() }],\n [key, ...state.order.filter((k) => k !== key)],\n state.activeKey,\n ),\n );\n\n notify(depth);\n}\n\n/**\n * Reveal a page the boundaries are still holding, without asking the server.\n *\n * Restoring is anchored on the deepest boundary that can show the page. Deeper\n * ones than that belonged to the page being left — a section with its own\n * layout adds a boundary the page you are going back to never had — so they\n * are dropped, exactly as setSegment drops them. Requiring every boundary to\n * hold the key instead made any such page refuse to restore.\n *\n * Shallower boundaries need no key of their own: their trees contain the\n * deeper boundary, so they delegate to whatever it is showing. One that does\n * hold the key is switched to it, since that is a real change at its level.\n */\n/**\n * Reveal a page still being held, if it is worth revealing.\n *\n * `maxAge` is what a link passes and the back button does not. Going back is\n * unambiguous — it names a moment, and the page from that moment is the right\n * answer however old. A link says \"go here\", and answering it with a tree from\n * twenty minutes ago is stale data presented as fresh, which is the objection\n * this design started with. Recent enough, and it is the same page you were\n * just on, with the form you were filling in still filled in.\n */\nexport function restoreSegments(key: string, maxAge?: number): boolean {\n const holding = [...depths.keys()].filter((d) =>\n depths.get(d)!.entries.some((entry) => entry.key === key),\n );\n\n if (holding.length === 0) return false;\n\n if (maxAge !== undefined) {\n const ages = holding.flatMap((d) =>\n depths\n .get(d)!\n .entries.filter((entry) => entry.key === key)\n .map((entry) => entry.at),\n );\n\n // The oldest layer decides: revealing a fresh page under a stale layout\n // would be a chain nobody rendered together.\n // >= rather than >, so a window of 0 means never rather than \"only within\n // the same millisecond\".\n if (Date.now() - Math.min(...ages) >= maxAge) return false;\n }\n\n const anchor = Math.max(...holding);\n\n for (const d of [...depths.keys()].filter((d) => d > anchor)) {\n depths.delete(d);\n notify(d);\n }\n\n for (const d of holding) {\n const state = depths.get(d)!;\n\n depths.set(\n d,\n retain(\n state.entries,\n [...state.order.filter((k) => k !== key), key],\n key,\n ),\n );\n notify(d);\n }\n\n return true;\n}\n\n/**\n * Drop everything, so boundaries fall back to their server-given children.\n *\n * A deployment invalidates them all: a segment from the previous build has no\n * claim on being correct for this one.\n */\nexport function clearSegments(): void {\n const all = [...depths.keys()];\n depths.clear();\n for (const depth of all) notify(depth);\n}\n\n/**\n * Whether a navigation has happened in this document.\n *\n * The first commit after hydration is the boundary taking over its\n * server-rendered children - the same page, re-keyed for retention. A view\n * transition for that commit fades the page into itself: the blank-then-\n * content a first load or a reload showed. Inertia transitions on visits and\n * never on load; so does this. The router notes the first navigation before\n * it updates a segment, and the boundary animates from then on.\n */\nlet navigated = false;\n\nexport function noteNavigation(): void {\n navigated = true;\n}\n\nexport function navigatedOnce(): boolean {\n return navigated;\n}\n"]}
1
+ {"version":3,"file":"segmentStore.js","sourceRoot":"","sources":["../../src/js/segmentStore.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * What each segment boundary is showing, and what it is keeping alive behind it.\n *\n * A navigation replaces one segment. Keeping the previous one mounted — hidden,\n * not unmounted — is what lets going back restore it with its client state:\n * the half-typed form, the open disclosure, the scrolled list. Unmounting\n * throws all of that away, which is what replacing the root used to do.\n *\n * Entries are keyed by page (the URL, or its intercept variant), so a boundary\n * can hold several and reveal one. Empty is meaningful: a boundary with nothing\n * stored renders the children the server gave it.\n *\n * A store rather than state, and not a preference — three things rule state out.\n * A boundary is inserted between every layout level, so there are several and a\n * navigation targets one by depth; they are separated by server components, so\n * no setter can be threaded down to them, because a function does not cross\n * that boundary; and navigate.ts is a plain module with no component instance\n * to call one on. Addressing a component you hold no reference to is what an\n * external store is for.\n *\n * Underneath it is the wire protocol. A partial navigation sends only the\n * segment that changed — see the X-RSC-Segments headers — so there is nothing\n * for a root to re-render with even if it held the state. Next.js keeps its\n * router in useState and derives every segment from it; that is the same trade\n * in the other direction.\n *\n * What it costs: React pins an external store's updates to synchronous\n * priority, because a store cannot be safely time-sliced. Synchronous is never\n * a transition, and anything that only runs for one — React's <ViewTransition>\n * among them — never ran for a navigation.\n *\n * Which is why SegmentBoundary does not read this with useSyncExternalStore\n * any more. The store still does the addressing, which is the part only it can\n * do; the boundary copies into state, so the render is a transition. See the\n * view transitions guide.\n */\n\ntype Tree = unknown;\ntype Listener = () => void;\n\ninterface Entry {\n key: string;\n tree: Tree;\n /**\n * When this tree arrived, so a link can decide whether it is still worth\n * revealing. The back button never asks — it means \"the page I was on\",\n * however long ago that was.\n */\n at: number;\n /**\n * Rendered before the click, hidden, on the strength of a touch or a\n * settled hover - see prerenderSegment. Outside the retention window: it\n * is a guess, and a guess must not evict a page the visitor was on. One\n * per depth; the next guess replaces it, and a navigation to anything\n * else drops it.\n */\n speculative?: boolean;\n}\n\n/**\n * Immutable: useSyncExternalStore compares snapshots by identity, so a new\n * object per read reads as \"changed every render\" and loops forever. Every\n * mutation replaces this wholesale; nothing edits one in place.\n */\ninterface DepthState {\n readonly entries: readonly Entry[];\n readonly activeKey: string;\n /** Most recently shown last; eviction takes from the front. */\n readonly order: readonly string[];\n}\n\n/** Pages kept alive per boundary. Four covers ordinary back-and-forth. */\nexport const RETENTION = 4;\n\nconst depths = new Map<number, DepthState>();\n/** When a mutation last made everything held before it wrong. */\nlet invalidatedAt = 0;\nconst listeners = new Map<number, Set<Listener>>();\n\nfunction notify(depth: number): void {\n for (const listener of listeners.get(depth) ?? []) listener();\n}\n\nexport function subscribeToSegment(\n depth: number,\n listener: Listener,\n): () => void {\n let set = listeners.get(depth);\n\n if (!set) {\n set = new Set();\n listeners.set(depth, set);\n }\n\n set.add(listener);\n\n return () => {\n set!.delete(listener);\n if (set!.size === 0) listeners.delete(depth);\n };\n}\n\n/** Everything a boundary at this depth needs to render, or null for \"use children\". */\nexport function getSegmentState(depth: number): DepthState | null {\n return depths.get(depth) ?? null;\n}\n\n/** Apply the retention window to a candidate state. */\nfunction retain(\n entries: readonly Entry[],\n order: readonly string[],\n activeKey: string,\n): DepthState {\n const kept = order.slice(-RETENTION);\n\n return {\n entries: entries.filter((entry) => kept.includes(entry.key) || entry.speculative),\n order: kept,\n activeKey,\n };\n}\n\nfunction put(depth: number, key: string, tree: Tree): void {\n const state = depths.get(depth);\n const entries = [\n ...(state?.entries ?? []).filter((entry) => entry.key !== key),\n { key, tree, at: Date.now() },\n ];\n const order = [...(state?.order ?? []).filter((k) => k !== key), key];\n\n depths.set(depth, retain(entries, order, key));\n}\n\n/**\n * Show `tree` at `depth` for `key`, retaining what was there.\n *\n * Deeper segments belonged to the page being replaced; leaving them would\n * render the previous page inside the new one.\n */\nexport function setSegment(depth: number, key: string, tree: Tree): void {\n // A guess about another page was wrong; the one about this page, if there\n // was one, is replaced by put() below - with the same tree, when the\n // prerender and the navigation read the same decoded payload, which is\n // what makes the update a reveal rather than a render.\n dropSpeculative(depth, key);\n put(depth, key, tree);\n\n const stale = [...depths.keys()].filter((d) => d > depth);\n for (const d of stale) depths.delete(d);\n\n notify(depth);\n for (const d of stale) notify(d);\n}\n\nfunction dropSpeculative(depth: number, except?: string): void {\n const state = depths.get(depth);\n\n if (!state?.entries.some((entry) => entry.speculative && entry.key !== except)) return;\n\n depths.set(depth, {\n ...state,\n entries: state.entries.filter((entry) => !entry.speculative || entry.key === except),\n });\n}\n\n/**\n * Render a page hidden at `depth`, before any navigation to it.\n *\n * The click then finds the work done: setSegment with the same tree is a\n * bail-out for React - same element, same props - and the Activity flips\n * from hidden to visible. What was 87 ms of rendering on a phone, after\n * the tap, is paid before the finger lifts, at idle priority, yielding to\n * the scroll. Never changes what is showing, never counts against\n * retention, and a page already held needs nothing.\n */\nexport function prerenderSegment(depth: number, key: string, tree: Tree): void {\n const state = depths.get(depth);\n\n // Nothing at this depth yet: the boundary is still showing the server's\n // children, and it has no page key to keep them under. A guess would take\n // the store over with no active entry to show. seedSegment runs on mount,\n // so this is the gap between hydration and that effect; the click will\n // render.\n if (!state) return;\n\n if (state.entries.some((entry) => entry.key === key)) return;\n\n depths.set(depth, {\n ...state,\n entries: [\n ...state.entries.filter((entry) => !entry.speculative),\n { key, tree, at: Date.now(), speculative: true },\n ],\n });\n\n notify(depth);\n}\n\n/** Whether a page is rendered hidden at `depth`, ahead of a navigation to it. */\nexport function isPrerendered(depth: number, key: string): boolean {\n return depths.get(depth)?.entries.some((entry) => entry.key === key && entry.speculative) ?? false;\n}\n\n/**\n * Give the page on screen at `depth` a new tree, under the key it has.\n *\n * For revalidate(\"all\"): the whole document rendered again, in place. The\n * root hands its layout the new tree, the layout hands its boundary new\n * children, and a boundary that holds state would otherwise keep showing\n * the store's tree and drop the new one on the floor - or, cleared first,\n * re-key its Activity to the current url and remount everything under it.\n * Neither. The entry that is showing takes the new tree and keeps its key,\n * so React reconciles the page in place, and the boundary below gets its\n * new children the same way.\n */\nexport function replaceActive(depth: number, tree: Tree): void {\n const state = depths.get(depth);\n\n if (!state) return;\n\n const active = state.entries.find((entry) => entry.key === state.activeKey);\n\n if (!active || active.tree === tree) return;\n\n depths.set(depth, {\n ...state,\n entries: state.entries.map((entry) => (entry === active ? { ...entry, tree, at: Date.now() } : entry)),\n });\n notify(depth);\n}\n\n/**\n * Record the children the server rendered, so the page you arrived on can be\n * returned to later. Never changes what is showing.\n */\nexport function seedSegment(depth: number, key: string, tree: Tree): void {\n const state = depths.get(depth);\n\n if (state?.entries.some((entry) => entry.key === key)) return;\n\n if (!state) {\n put(depth, key, tree);\n notify(depth);\n\n return;\n }\n\n // Older than whatever is showing, so it goes to the front of the eviction\n // order — and crucially does not become the active page.\n depths.set(\n depth,\n retain(\n [...state.entries, { key, tree, at: Date.now() }],\n [key, ...state.order.filter((k) => k !== key)],\n state.activeKey,\n ),\n );\n\n notify(depth);\n}\n\n/**\n * Reveal a page the boundaries are still holding, without asking the server.\n *\n * Restoring is anchored on the deepest boundary that can show the page. Deeper\n * ones than that belonged to the page being left — a section with its own\n * layout adds a boundary the page you are going back to never had — so they\n * are dropped, exactly as setSegment drops them. Requiring every boundary to\n * hold the key instead made any such page refuse to restore.\n *\n * Shallower boundaries need no key of their own: their trees contain the\n * deeper boundary, so they delegate to whatever it is showing. One that does\n * hold the key is switched to it, since that is a real change at its level.\n */\n/**\n * Whether a link to `key` would be answered by revealing a held page.\n *\n * The question restoreSegments answers, asked without acting on it: a\n * prefetch of a page the boundaries still hold is a request for nothing -\n * a navigation would reveal it. The page just left is the usual case, one\n * wasted payload per navigation.\n */\nexport function isHeld(key: string, maxAge?: number): boolean {\n const ages = [...depths.values()].flatMap((state) =>\n state.entries.filter((entry) => entry.key === key && !entry.speculative && entry.at >= invalidatedAt).map((entry) => entry.at),\n );\n\n if (ages.length === 0) return false;\n if (maxAge === undefined) return true;\n\n return Date.now() - Math.min(...ages) < maxAge;\n}\n\n/**\n * Drop every page held behind the one on screen.\n *\n * After a mutation: a page kept for the back button holds the data from\n * before it, and revealing it would show a row that is gone, a name that\n * changed. The page on screen stays - it was re-rendered by the action, or\n * is about to be.\n */\nexport function dropHidden(): void {\n // And the ones that stay - the active entry at each depth - are from\n // before it too. A layout's entry is keyed by the page it was seeded\n // with, and revealing that key later shows the page inside it as it was\n // then. Nothing seeded before this moment is revealed again.\n invalidatedAt = Date.now();\n\n for (const [depth, state] of depths) {\n if (state.entries.length <= 1 && !state.entries.some((entry) => entry.speculative)) continue;\n\n depths.set(depth, {\n entries: state.entries.filter((entry) => entry.key === state.activeKey),\n order: state.order.filter((key) => key === state.activeKey),\n activeKey: state.activeKey,\n });\n notify(depth);\n }\n}\n\n/**\n * Reveal a page still being held, if it is worth revealing.\n *\n * `maxAge` is what a link passes and the back button does not. Going back is\n * unambiguous — it names a moment, and the page from that moment is the right\n * answer however old. A link says \"go here\", and answering it with a tree from\n * twenty minutes ago is stale data presented as fresh, which is the objection\n * this design started with. Recent enough, and it is the same page you were\n * just on, with the form you were filling in still filled in.\n */\nexport function restoreSegments(key: string, maxAge?: number): boolean {\n // A guess is not a held page: revealing it would be showing prefetched\n // data as the page the visitor was on. The navigation takes the\n // prerendered tree through setSegment instead, where it is a reveal too.\n // Nor is a page from before a mutation - see dropHidden.\n const holding = [...depths.keys()].filter((d) =>\n depths.get(d)!.entries.some((entry) => entry.key === key && !entry.speculative && entry.at >= invalidatedAt),\n );\n\n if (holding.length === 0) return false;\n\n if (maxAge !== undefined) {\n const ages = holding.flatMap((d) =>\n depths\n .get(d)!\n .entries.filter((entry) => entry.key === key)\n .map((entry) => entry.at),\n );\n\n // The oldest layer decides: revealing a fresh page under a stale layout\n // would be a chain nobody rendered together.\n // >= rather than >, so a window of 0 means never rather than \"only within\n // the same millisecond\".\n if (Date.now() - Math.min(...ages) >= maxAge) return false;\n }\n\n const anchor = Math.max(...holding);\n\n for (const d of [...depths.keys()].filter((d) => d > anchor)) {\n depths.delete(d);\n notify(d);\n }\n\n for (const d of holding) {\n dropSpeculative(d);\n\n const state = depths.get(d)!;\n\n depths.set(\n d,\n retain(\n state.entries,\n [...state.order.filter((k) => k !== key), key],\n key,\n ),\n );\n notify(d);\n }\n\n return true;\n}\n\n/**\n * Drop everything, so boundaries fall back to their server-given children.\n *\n * A deployment invalidates them all: a segment from the previous build has no\n * claim on being correct for this one.\n */\nexport function clearSegments(): void {\n const all = [...depths.keys()];\n depths.clear();\n for (const depth of all) notify(depth);\n}\n\n/**\n * Whether a navigation has happened in this document.\n *\n * The first commit after hydration is the boundary taking over its\n * server-rendered children - the same page, re-keyed for retention. A view\n * transition for that commit fades the page into itself: the blank-then-\n * content a first load or a reload showed. Inertia transitions on visits and\n * never on load; so does this. The router notes the first navigation before\n * it updates a segment, and the boundary animates from then on.\n */\nlet navigated = false;\n\nexport function noteNavigation(): void {\n navigated = true;\n}\n\nexport function navigatedOnce(): boolean {\n return navigated;\n}\n"]}
@@ -23,5 +23,15 @@ export declare function isStaleAssetError(error: unknown): boolean;
23
23
  * place is the one refused.
24
24
  */
25
25
  export declare function loadDocumentOnce(href?: string): boolean;
26
+ /**
27
+ * Say why the router is loading a document, before it does.
28
+ *
29
+ * `rsc-kit:document-load`, with the url and the reason: a document load is
30
+ * the one thing the router does that looks like a bug when it was a
31
+ * decision, and a page that reloaded under a tester with nothing in the
32
+ * console had no way to say which decision. An app listens to record it; a
33
+ * test listens to assert on it.
34
+ */
35
+ export declare function announceDocumentLoad(url: string, reason: string): void;
26
36
  /** True when the page is being reloaded for it; false when the error is something else, or reloading already failed. */
27
37
  export declare function recoverFromStaleAssets(error: unknown): boolean;
@@ -54,12 +54,33 @@ export function loadDocumentOnce(href) {
54
54
  catch {
55
55
  // No storage: load anyway, once is the best that can be promised.
56
56
  }
57
- if (href === undefined)
57
+ if (href === undefined) {
58
+ announceDocumentLoad(window.location.href, "reload");
58
59
  window.location.reload();
59
- else
60
+ }
61
+ else {
62
+ announceDocumentLoad(href, "stale-or-newer-build");
60
63
  window.location.href = href;
64
+ }
61
65
  return true;
62
66
  }
67
+ /**
68
+ * Say why the router is loading a document, before it does.
69
+ *
70
+ * `rsc-kit:document-load`, with the url and the reason: a document load is
71
+ * the one thing the router does that looks like a bug when it was a
72
+ * decision, and a page that reloaded under a tester with nothing in the
73
+ * console had no way to say which decision. An app listens to record it; a
74
+ * test listens to assert on it.
75
+ */
76
+ export function announceDocumentLoad(url, reason) {
77
+ try {
78
+ window.dispatchEvent(new CustomEvent("rsc-kit:document-load", { detail: { url, reason } }));
79
+ }
80
+ catch {
81
+ // Nothing to tell.
82
+ }
83
+ }
63
84
  /** True when the page is being reloaded for it; false when the error is something else, or reloading already failed. */
64
85
  export function recoverFromStaleAssets(error) {
65
86
  if (!isStaleAssetError(error))
@@ -1 +1 @@
1
- {"version":3,"file":"staleAssets.js","sourceRoot":"","sources":["../../src/js/staleAssets.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,6EAA6E;AAC7E,2EAA2E;AAC3E,4EAA4E;AAC5E,mEAAmE;AACnE,MAAM,YAAY,GAChB,4MAA4M,CAAC;AAE/M,MAAM,QAAQ,GAAG,kBAAkB,CAAC;AACpC,MAAM,SAAS,GAAG,MAAM,CAAC;AAEzB;;;;;GAKG;AACH,MAAM,WAAW,GAAG,4EAA4E,CAAC;AAEjG,MAAM,UAAU,iBAAiB,CAAC,KAAc;IAC9C,MAAM,OAAO,GAAG,MAAM,CAAE,KAAqC,EAAE,OAAO,IAAI,KAAK,CAAC,CAAC;IAEjF,IAAI,YAAY,CAAC,IAAI,CAAC,OAAO,CAAC;QAAE,OAAO,IAAI,CAAC;IAE5C,OAAO,CAAC,CAAC,OAAO,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,WAAW,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;AAC7D,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,gBAAgB,CAAC,IAAa;IAC5C,IAAI,OAAO,MAAM,KAAK,WAAW;QAAE,OAAO,KAAK,CAAC;IAEhD,MAAM,IAAI,GAAG,GAAG,QAAQ,IAAI,MAAM,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC;IAEnD,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,MAAM,CAAC,cAAc,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC;QAEvD,IAAI,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,GAAG,SAAS;YAAE,OAAO,KAAK,CAAC;QAEhD,cAAc,CAAC,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC;IACnD,CAAC;IAAC,MAAM,CAAC;QACP,kEAAkE;IACpE,CAAC;IAED,IAAI,IAAI,KAAK,SAAS;QAAE,MAAM,CAAC,QAAQ,CAAC,MAAM,EAAE,CAAC;;QAC5C,MAAM,CAAC,QAAQ,CAAC,IAAI,GAAG,IAAI,CAAC;IAEjC,OAAO,IAAI,CAAC;AACd,CAAC;AAED,wHAAwH;AACxH,MAAM,UAAU,sBAAsB,CAAC,KAAc;IACnD,IAAI,CAAC,iBAAiB,CAAC,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC;IAE5C,OAAO,gBAAgB,EAAE,CAAC;AAC5B,CAAC","sourcesContent":["/**\n * A navigation that lands on a chunk that is no longer there.\n *\n * After a deploy the open tab still holds the old page, and its next\n * navigation asks for client components by the old hashed names, which the\n * new deploy does not serve. The dev server does the same when it\n * re-optimises dependencies and answers the old names with 504. Either way\n * the payload arrives and React fails to load the module it names, on a\n * page that was working a click ago.\n *\n * The fix is the one the browser would have applied: load the document again.\n * Once — a second failure on the same url within a few seconds means the\n * deploy is broken, not stale, and reloading forever would hide that.\n */\n\n// A reference the payload names and this client's manifest lacks is the same\n// news as a chunk that is gone: the payload is from a newer build than the\n// page. Under a service worker that serves the last build's document first,\n// it is every returning visitor's first navigation after a deploy.\nconst STALE_MODULE =\n /Failed to fetch dynamically imported module|Importing a module script failed|error loading dynamically imported module|Outdated Optimize Dep|Loading (?:CSS )?chunk|(?:client|server) reference not found/i;\n\nconst RELOADED = \"rsc-kit:reloaded\";\nconst WINDOW_MS = 10_000;\n\n/**\n * Development only: the shape a page takes when Vite re-optimised the\n * browser's dependencies underneath it. Modules already loaded hold the old\n * React, freshly loaded ones the new; a hook then reads a null dispatcher.\n * The page is over either way; loading it again is what Vite would do next.\n */\nconst MIXED_REACT = /Invalid hook call|Cannot read properties of null \\(reading 'use[A-Z]\\w*'\\)/;\n\nexport function isStaleAssetError(error: unknown): boolean {\n const message = String((error as { message?: string } | null)?.message ?? error);\n\n if (STALE_MODULE.test(message)) return true;\n\n return !!import.meta.env?.DEV && MIXED_REACT.test(message);\n}\n\n/**\n * Load a document, once: this one again, or the one at `href`.\n *\n * False when the same url was loaded this way within the window - the deploy\n * is broken, not stale, or a worker still serving the last build's document\n * answered the reload with it - and loading forever would hide that. The\n * mark is keyed by where the page is, so the second attempt from the same\n * place is the one refused.\n */\nexport function loadDocumentOnce(href?: string): boolean {\n if (typeof window === \"undefined\") return false;\n\n const mark = `${RELOADED}:${window.location.href}`;\n\n try {\n const last = Number(sessionStorage.getItem(mark) ?? 0);\n\n if (Date.now() - last < WINDOW_MS) return false;\n\n sessionStorage.setItem(mark, String(Date.now()));\n } catch {\n // No storage: load anyway, once is the best that can be promised.\n }\n\n if (href === undefined) window.location.reload();\n else window.location.href = href;\n\n return true;\n}\n\n/** True when the page is being reloaded for it; false when the error is something else, or reloading already failed. */\nexport function recoverFromStaleAssets(error: unknown): boolean {\n if (!isStaleAssetError(error)) return false;\n\n return loadDocumentOnce();\n}\n"]}
1
+ {"version":3,"file":"staleAssets.js","sourceRoot":"","sources":["../../src/js/staleAssets.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,6EAA6E;AAC7E,2EAA2E;AAC3E,4EAA4E;AAC5E,mEAAmE;AACnE,MAAM,YAAY,GAChB,4MAA4M,CAAC;AAE/M,MAAM,QAAQ,GAAG,kBAAkB,CAAC;AACpC,MAAM,SAAS,GAAG,MAAM,CAAC;AAEzB;;;;;GAKG;AACH,MAAM,WAAW,GAAG,4EAA4E,CAAC;AAEjG,MAAM,UAAU,iBAAiB,CAAC,KAAc;IAC9C,MAAM,OAAO,GAAG,MAAM,CAAE,KAAqC,EAAE,OAAO,IAAI,KAAK,CAAC,CAAC;IAEjF,IAAI,YAAY,CAAC,IAAI,CAAC,OAAO,CAAC;QAAE,OAAO,IAAI,CAAC;IAE5C,OAAO,CAAC,CAAC,OAAO,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,WAAW,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;AAC7D,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,gBAAgB,CAAC,IAAa;IAC5C,IAAI,OAAO,MAAM,KAAK,WAAW;QAAE,OAAO,KAAK,CAAC;IAEhD,MAAM,IAAI,GAAG,GAAG,QAAQ,IAAI,MAAM,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC;IAEnD,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,MAAM,CAAC,cAAc,CAAC,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC;QAEvD,IAAI,IAAI,CAAC,GAAG,EAAE,GAAG,IAAI,GAAG,SAAS;YAAE,OAAO,KAAK,CAAC;QAEhD,cAAc,CAAC,OAAO,CAAC,IAAI,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC;IACnD,CAAC;IAAC,MAAM,CAAC;QACP,kEAAkE;IACpE,CAAC;IAED,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;QACvB,oBAAoB,CAAC,MAAM,CAAC,QAAQ,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAC;QACrD,MAAM,CAAC,QAAQ,CAAC,MAAM,EAAE,CAAC;IAC3B,CAAC;SAAM,CAAC;QACN,oBAAoB,CAAC,IAAI,EAAE,sBAAsB,CAAC,CAAC;QACnD,MAAM,CAAC,QAAQ,CAAC,IAAI,GAAG,IAAI,CAAC;IAC9B,CAAC;IAED,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,oBAAoB,CAAC,GAAW,EAAE,MAAc;IAC9D,IAAI,CAAC;QACH,MAAM,CAAC,aAAa,CAAC,IAAI,WAAW,CAAC,uBAAuB,EAAE,EAAE,MAAM,EAAE,EAAE,GAAG,EAAE,MAAM,EAAE,EAAE,CAAC,CAAC,CAAC;IAC9F,CAAC;IAAC,MAAM,CAAC;QACP,mBAAmB;IACrB,CAAC;AACH,CAAC;AAED,wHAAwH;AACxH,MAAM,UAAU,sBAAsB,CAAC,KAAc;IACnD,IAAI,CAAC,iBAAiB,CAAC,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC;IAE5C,OAAO,gBAAgB,EAAE,CAAC;AAC5B,CAAC","sourcesContent":["/**\n * A navigation that lands on a chunk that is no longer there.\n *\n * After a deploy the open tab still holds the old page, and its next\n * navigation asks for client components by the old hashed names, which the\n * new deploy does not serve. The dev server does the same when it\n * re-optimises dependencies and answers the old names with 504. Either way\n * the payload arrives and React fails to load the module it names, on a\n * page that was working a click ago.\n *\n * The fix is the one the browser would have applied: load the document again.\n * Once — a second failure on the same url within a few seconds means the\n * deploy is broken, not stale, and reloading forever would hide that.\n */\n\n// A reference the payload names and this client's manifest lacks is the same\n// news as a chunk that is gone: the payload is from a newer build than the\n// page. Under a service worker that serves the last build's document first,\n// it is every returning visitor's first navigation after a deploy.\nconst STALE_MODULE =\n /Failed to fetch dynamically imported module|Importing a module script failed|error loading dynamically imported module|Outdated Optimize Dep|Loading (?:CSS )?chunk|(?:client|server) reference not found/i;\n\nconst RELOADED = \"rsc-kit:reloaded\";\nconst WINDOW_MS = 10_000;\n\n/**\n * Development only: the shape a page takes when Vite re-optimised the\n * browser's dependencies underneath it. Modules already loaded hold the old\n * React, freshly loaded ones the new; a hook then reads a null dispatcher.\n * The page is over either way; loading it again is what Vite would do next.\n */\nconst MIXED_REACT = /Invalid hook call|Cannot read properties of null \\(reading 'use[A-Z]\\w*'\\)/;\n\nexport function isStaleAssetError(error: unknown): boolean {\n const message = String((error as { message?: string } | null)?.message ?? error);\n\n if (STALE_MODULE.test(message)) return true;\n\n return !!import.meta.env?.DEV && MIXED_REACT.test(message);\n}\n\n/**\n * Load a document, once: this one again, or the one at `href`.\n *\n * False when the same url was loaded this way within the window - the deploy\n * is broken, not stale, or a worker still serving the last build's document\n * answered the reload with it - and loading forever would hide that. The\n * mark is keyed by where the page is, so the second attempt from the same\n * place is the one refused.\n */\nexport function loadDocumentOnce(href?: string): boolean {\n if (typeof window === \"undefined\") return false;\n\n const mark = `${RELOADED}:${window.location.href}`;\n\n try {\n const last = Number(sessionStorage.getItem(mark) ?? 0);\n\n if (Date.now() - last < WINDOW_MS) return false;\n\n sessionStorage.setItem(mark, String(Date.now()));\n } catch {\n // No storage: load anyway, once is the best that can be promised.\n }\n\n if (href === undefined) {\n announceDocumentLoad(window.location.href, \"reload\");\n window.location.reload();\n } else {\n announceDocumentLoad(href, \"stale-or-newer-build\");\n window.location.href = href;\n }\n\n return true;\n}\n\n/**\n * Say why the router is loading a document, before it does.\n *\n * `rsc-kit:document-load`, with the url and the reason: a document load is\n * the one thing the router does that looks like a bug when it was a\n * decision, and a page that reloaded under a tester with nothing in the\n * console had no way to say which decision. An app listens to record it; a\n * test listens to assert on it.\n */\nexport function announceDocumentLoad(url: string, reason: string): void {\n try {\n window.dispatchEvent(new CustomEvent(\"rsc-kit:document-load\", { detail: { url, reason } }));\n } catch {\n // Nothing to tell.\n }\n}\n\n/** True when the page is being reloaded for it; false when the error is something else, or reloading already failed. */\nexport function recoverFromStaleAssets(error: unknown): boolean {\n if (!isStaleAssetError(error)) return false;\n\n return loadDocumentOnce();\n}\n"]}
@@ -1,13 +1,22 @@
1
1
  /**
2
- * Prefetching a link as it comes into view, where there is no pointer to hover it.
2
+ * Prefetching a link as it comes into view.
3
3
  *
4
- * On a phone the first signal a tap gives is touchstart, and the click lands
5
- * 100-300 ms after it - about a round trip - so a prefetch started there has
6
- * barely left when the navigation needs it. Next prefetches the links on
7
- * screen instead, and that is what makes its taps feel instant on a phone.
8
- * Only on a device with no hover: a pointer that can settle on a link is a
9
- * better signal than a link merely being visible, and cheaper on a page with
10
- * a hundred of them.
4
+ * A navigation is as fast as what is already in the browser when the click
5
+ * lands, and a hover is too late to fetch that: a pointer that has settled
6
+ * on a link is 200-300 ms from clicking it, and on a phone the first signal
7
+ * is touchstart, 100-300 ms before the click - about a round trip either
8
+ * way, so a fetch started there has barely left when the navigation needs
9
+ * it. Next prefetches the links on screen instead, and that is what makes
10
+ * its clicks feel like a single-page app's: the payload was there before
11
+ * the pointer moved. So, on every device, the links on screen are fetched -
12
+ * the bytes only; decoding a payload loads the chunks it names, and that
13
+ * waits for the hover or the touch that says which link is meant. It used
14
+ * to be phones only, with a hover-capable device left to the hover: a click
15
+ * that came quicker than the round trip waited for it, and desktop was
16
+ * fast but not instant.
17
+ *
18
+ * Not when the visitor asked for less data: Save-Data is the one signal a
19
+ * browser gives that speculative requests are unwelcome.
11
20
  *
12
21
  * One observer for every link, and the work is done when the browser is
13
22
  * idle - a list scrolled into view is many links at once, and the browser's
@@ -15,7 +24,7 @@
15
24
  * yet. Each link prefetches once; the router keeps the payload for its TTL.
16
25
  */
17
26
  /**
18
- * Prefetch when the link is on screen, on a device with no hover. Returns
19
- * the function that stops watching; a no-op where this does not apply.
27
+ * Prefetch when the link is on screen. Returns the function that stops
28
+ * watching; a no-op where this does not apply.
20
29
  */
21
30
  export declare function prefetchWhenVisible(element: Element | null, fire: () => void): () => void;
@@ -1,13 +1,22 @@
1
1
  /**
2
- * Prefetching a link as it comes into view, where there is no pointer to hover it.
2
+ * Prefetching a link as it comes into view.
3
3
  *
4
- * On a phone the first signal a tap gives is touchstart, and the click lands
5
- * 100-300 ms after it - about a round trip - so a prefetch started there has
6
- * barely left when the navigation needs it. Next prefetches the links on
7
- * screen instead, and that is what makes its taps feel instant on a phone.
8
- * Only on a device with no hover: a pointer that can settle on a link is a
9
- * better signal than a link merely being visible, and cheaper on a page with
10
- * a hundred of them.
4
+ * A navigation is as fast as what is already in the browser when the click
5
+ * lands, and a hover is too late to fetch that: a pointer that has settled
6
+ * on a link is 200-300 ms from clicking it, and on a phone the first signal
7
+ * is touchstart, 100-300 ms before the click - about a round trip either
8
+ * way, so a fetch started there has barely left when the navigation needs
9
+ * it. Next prefetches the links on screen instead, and that is what makes
10
+ * its clicks feel like a single-page app's: the payload was there before
11
+ * the pointer moved. So, on every device, the links on screen are fetched -
12
+ * the bytes only; decoding a payload loads the chunks it names, and that
13
+ * waits for the hover or the touch that says which link is meant. It used
14
+ * to be phones only, with a hover-capable device left to the hover: a click
15
+ * that came quicker than the round trip waited for it, and desktop was
16
+ * fast but not instant.
17
+ *
18
+ * Not when the visitor asked for less data: Save-Data is the one signal a
19
+ * browser gives that speculative requests are unwelcome.
11
20
  *
12
21
  * One observer for every link, and the work is done when the browser is
13
22
  * idle - a list scrolled into view is many links at once, and the browser's
@@ -16,8 +25,29 @@
16
25
  */
17
26
  let observer = null;
18
27
  const pending = new WeakMap();
19
- function noHover() {
20
- return typeof window !== "undefined" && typeof window.matchMedia === "function" && window.matchMedia("(hover: none)").matches;
28
+ /**
29
+ * How many links a page prefetches on sight before the rest wait for
30
+ * intent. A product page's payload is 30 KB with its related products,
31
+ * and a listing shows twenty-four of them: on sight, every one, that was
32
+ * three quarters of a megabyte per page on a phone. The first dozen in
33
+ * view are fetched; a link past the budget is fetched on touch or on a
34
+ * settled hover, a round trip before the click rather than before the
35
+ * scroll. The budget is the page's, and starts again on each navigation.
36
+ */
37
+ const ON_SIGHT_PER_PAGE = 12;
38
+ let onSight = 0;
39
+ let listening = false;
40
+ function budgetPerPage() {
41
+ if (listening || typeof window === "undefined")
42
+ return;
43
+ listening = true;
44
+ window.addEventListener("rsc-navigate", () => {
45
+ onSight = 0;
46
+ });
47
+ }
48
+ function savingData() {
49
+ const connection = navigator.connection;
50
+ return connection?.saveData === true;
21
51
  }
22
52
  function whenIdle(fn) {
23
53
  const idle = window.requestIdleCallback;
@@ -38,8 +68,12 @@ function observerFor() {
38
68
  const fire = pending.get(entry.target);
39
69
  observer.unobserve(entry.target);
40
70
  pending.delete(entry.target);
41
- if (fire)
42
- whenIdle(fire);
71
+ if (!fire)
72
+ continue;
73
+ if (onSight >= ON_SIGHT_PER_PAGE)
74
+ continue;
75
+ onSight++;
76
+ whenIdle(fire);
43
77
  }
44
78
  },
45
79
  // A little ahead of the fold: a link about to scroll into view is one
@@ -48,12 +82,13 @@ function observerFor() {
48
82
  return observer;
49
83
  }
50
84
  /**
51
- * Prefetch when the link is on screen, on a device with no hover. Returns
52
- * the function that stops watching; a no-op where this does not apply.
85
+ * Prefetch when the link is on screen. Returns the function that stops
86
+ * watching; a no-op where this does not apply.
53
87
  */
54
88
  export function prefetchWhenVisible(element, fire) {
55
- if (!element || !noHover())
89
+ if (!element || savingData())
56
90
  return () => { };
91
+ budgetPerPage();
57
92
  const io = observerFor();
58
93
  if (!io)
59
94
  return () => { };
@@ -1 +1 @@
1
- {"version":3,"file":"viewportPrefetch.js","sourceRoot":"","sources":["../../src/js/viewportPrefetch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,IAAI,QAAQ,GAAgC,IAAI,CAAA;AAChD,MAAM,OAAO,GAAG,IAAI,OAAO,EAAuB,CAAA;AAElD,SAAS,OAAO;IACd,OAAO,OAAO,MAAM,KAAK,WAAW,IAAI,OAAO,MAAM,CAAC,UAAU,KAAK,UAAU,IAAI,MAAM,CAAC,UAAU,CAAC,eAAe,CAAC,CAAC,OAAO,CAAA;AAC/H,CAAC;AAED,SAAS,QAAQ,CAAC,EAAc;IAC9B,MAAM,IAAI,GAAI,MAAyF,CAAC,mBAAmB,CAAA;IAE3H,IAAI,IAAI;QAAE,IAAI,CAAC,EAAE,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAA;;QAChC,UAAU,CAAC,EAAE,EAAE,EAAE,CAAC,CAAA;AACzB,CAAC;AAED,SAAS,WAAW;IAClB,IAAI,QAAQ;QAAE,OAAO,QAAQ,CAAA;IAC7B,IAAI,OAAO,oBAAoB,KAAK,WAAW;QAAE,OAAO,IAAI,CAAA;IAE5D,QAAQ,GAAG,IAAI,oBAAoB,CACjC,CAAC,OAAO,EAAE,EAAE;QACV,KAAK,MAAM,KAAK,IAAI,OAAO,EAAE,CAAC;YAC5B,IAAI,CAAC,KAAK,CAAC,cAAc;gBAAE,SAAQ;YAEnC,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC,CAAA;YAEtC,QAAS,CAAC,SAAS,CAAC,KAAK,CAAC,MAAM,CAAC,CAAA;YACjC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,CAAA;YAE5B,IAAI,IAAI;gBAAE,QAAQ,CAAC,IAAI,CAAC,CAAA;QAC1B,CAAC;IACH,CAAC;IACD,sEAAsE;IACtE,sBAAsB;IACtB,EAAE,UAAU,EAAE,OAAO,EAAE,CACxB,CAAA;IAED,OAAO,QAAQ,CAAA;AACjB,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,mBAAmB,CAAC,OAAuB,EAAE,IAAgB;IAC3E,IAAI,CAAC,OAAO,IAAI,CAAC,OAAO,EAAE;QAAE,OAAO,GAAG,EAAE,GAAE,CAAC,CAAA;IAE3C,MAAM,EAAE,GAAG,WAAW,EAAE,CAAA;IAExB,IAAI,CAAC,EAAE;QAAE,OAAO,GAAG,EAAE,GAAE,CAAC,CAAA;IAExB,OAAO,CAAC,GAAG,CAAC,OAAO,EAAE,IAAI,CAAC,CAAA;IAC1B,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,CAAA;IAEnB,OAAO,GAAG,EAAE;QACV,OAAO,CAAC,MAAM,CAAC,OAAO,CAAC,CAAA;QACvB,EAAE,CAAC,SAAS,CAAC,OAAO,CAAC,CAAA;IACvB,CAAC,CAAA;AACH,CAAC","sourcesContent":["/**\n * Prefetching a link as it comes into view, where there is no pointer to hover it.\n *\n * On a phone the first signal a tap gives is touchstart, and the click lands\n * 100-300 ms after it - about a round trip - so a prefetch started there has\n * barely left when the navigation needs it. Next prefetches the links on\n * screen instead, and that is what makes its taps feel instant on a phone.\n * Only on a device with no hover: a pointer that can settle on a link is a\n * better signal than a link merely being visible, and cheaper on a page with\n * a hundred of them.\n *\n * One observer for every link, and the work is done when the browser is\n * idle - a list scrolled into view is many links at once, and the browser's\n * per-origin connections should not all be taken by pages nobody has tapped\n * yet. Each link prefetches once; the router keeps the payload for its TTL.\n */\n\nlet observer: IntersectionObserver | null = null\nconst pending = new WeakMap<Element, () => void>()\n\nfunction noHover(): boolean {\n return typeof window !== \"undefined\" && typeof window.matchMedia === \"function\" && window.matchMedia(\"(hover: none)\").matches\n}\n\nfunction whenIdle(fn: () => void): void {\n const idle = (window as { requestIdleCallback?: (fn: () => void, opts?: { timeout: number }) => void }).requestIdleCallback\n\n if (idle) idle(fn, { timeout: 1000 })\n else setTimeout(fn, 50)\n}\n\nfunction observerFor(): IntersectionObserver | null {\n if (observer) return observer\n if (typeof IntersectionObserver === \"undefined\") return null\n\n observer = new IntersectionObserver(\n (entries) => {\n for (const entry of entries) {\n if (!entry.isIntersecting) continue\n\n const fire = pending.get(entry.target)\n\n observer!.unobserve(entry.target)\n pending.delete(entry.target)\n\n if (fire) whenIdle(fire)\n }\n },\n // A little ahead of the fold: a link about to scroll into view is one\n // about to be tapped.\n { rootMargin: \"200px\" },\n )\n\n return observer\n}\n\n/**\n * Prefetch when the link is on screen, on a device with no hover. Returns\n * the function that stops watching; a no-op where this does not apply.\n */\nexport function prefetchWhenVisible(element: Element | null, fire: () => void): () => void {\n if (!element || !noHover()) return () => {}\n\n const io = observerFor()\n\n if (!io) return () => {}\n\n pending.set(element, fire)\n io.observe(element)\n\n return () => {\n pending.delete(element)\n io.unobserve(element)\n }\n}\n"]}
1
+ {"version":3,"file":"viewportPrefetch.js","sourceRoot":"","sources":["../../src/js/viewportPrefetch.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,IAAI,QAAQ,GAAgC,IAAI,CAAA;AAChD,MAAM,OAAO,GAAG,IAAI,OAAO,EAAuB,CAAA;AAElD;;;;;;;;GAQG;AACH,MAAM,iBAAiB,GAAG,EAAE,CAAA;AAC5B,IAAI,OAAO,GAAG,CAAC,CAAA;AACf,IAAI,SAAS,GAAG,KAAK,CAAA;AAErB,SAAS,aAAa;IACpB,IAAI,SAAS,IAAI,OAAO,MAAM,KAAK,WAAW;QAAE,OAAM;IAEtD,SAAS,GAAG,IAAI,CAAA;IAChB,MAAM,CAAC,gBAAgB,CAAC,cAAc,EAAE,GAAG,EAAE;QAC3C,OAAO,GAAG,CAAC,CAAA;IACb,CAAC,CAAC,CAAA;AACJ,CAAC;AAED,SAAS,UAAU;IACjB,MAAM,UAAU,GAAI,SAAqD,CAAC,UAAU,CAAA;IAEpF,OAAO,UAAU,EAAE,QAAQ,KAAK,IAAI,CAAA;AACtC,CAAC;AAED,SAAS,QAAQ,CAAC,EAAc;IAC9B,MAAM,IAAI,GAAI,MAAyF,CAAC,mBAAmB,CAAA;IAE3H,IAAI,IAAI;QAAE,IAAI,CAAC,EAAE,EAAE,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAA;;QAChC,UAAU,CAAC,EAAE,EAAE,EAAE,CAAC,CAAA;AACzB,CAAC;AAED,SAAS,WAAW;IAClB,IAAI,QAAQ;QAAE,OAAO,QAAQ,CAAA;IAC7B,IAAI,OAAO,oBAAoB,KAAK,WAAW;QAAE,OAAO,IAAI,CAAA;IAE5D,QAAQ,GAAG,IAAI,oBAAoB,CACjC,CAAC,OAAO,EAAE,EAAE;QACV,KAAK,MAAM,KAAK,IAAI,OAAO,EAAE,CAAC;YAC5B,IAAI,CAAC,KAAK,CAAC,cAAc;gBAAE,SAAQ;YAEnC,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC,CAAA;YAEtC,QAAS,CAAC,SAAS,CAAC,KAAK,CAAC,MAAM,CAAC,CAAA;YACjC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,CAAA;YAE5B,IAAI,CAAC,IAAI;gBAAE,SAAQ;YACnB,IAAI,OAAO,IAAI,iBAAiB;gBAAE,SAAQ;YAE1C,OAAO,EAAE,CAAA;YACT,QAAQ,CAAC,IAAI,CAAC,CAAA;QAChB,CAAC;IACH,CAAC;IACD,sEAAsE;IACtE,sBAAsB;IACtB,EAAE,UAAU,EAAE,OAAO,EAAE,CACxB,CAAA;IAED,OAAO,QAAQ,CAAA;AACjB,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,mBAAmB,CAAC,OAAuB,EAAE,IAAgB;IAC3E,IAAI,CAAC,OAAO,IAAI,UAAU,EAAE;QAAE,OAAO,GAAG,EAAE,GAAE,CAAC,CAAA;IAE7C,aAAa,EAAE,CAAA;IAEf,MAAM,EAAE,GAAG,WAAW,EAAE,CAAA;IAExB,IAAI,CAAC,EAAE;QAAE,OAAO,GAAG,EAAE,GAAE,CAAC,CAAA;IAExB,OAAO,CAAC,GAAG,CAAC,OAAO,EAAE,IAAI,CAAC,CAAA;IAC1B,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,CAAA;IAEnB,OAAO,GAAG,EAAE;QACV,OAAO,CAAC,MAAM,CAAC,OAAO,CAAC,CAAA;QACvB,EAAE,CAAC,SAAS,CAAC,OAAO,CAAC,CAAA;IACvB,CAAC,CAAA;AACH,CAAC","sourcesContent":["/**\n * Prefetching a link as it comes into view.\n *\n * A navigation is as fast as what is already in the browser when the click\n * lands, and a hover is too late to fetch that: a pointer that has settled\n * on a link is 200-300 ms from clicking it, and on a phone the first signal\n * is touchstart, 100-300 ms before the click - about a round trip either\n * way, so a fetch started there has barely left when the navigation needs\n * it. Next prefetches the links on screen instead, and that is what makes\n * its clicks feel like a single-page app's: the payload was there before\n * the pointer moved. So, on every device, the links on screen are fetched -\n * the bytes only; decoding a payload loads the chunks it names, and that\n * waits for the hover or the touch that says which link is meant. It used\n * to be phones only, with a hover-capable device left to the hover: a click\n * that came quicker than the round trip waited for it, and desktop was\n * fast but not instant.\n *\n * Not when the visitor asked for less data: Save-Data is the one signal a\n * browser gives that speculative requests are unwelcome.\n *\n * One observer for every link, and the work is done when the browser is\n * idle - a list scrolled into view is many links at once, and the browser's\n * per-origin connections should not all be taken by pages nobody has tapped\n * yet. Each link prefetches once; the router keeps the payload for its TTL.\n */\n\nlet observer: IntersectionObserver | null = null\nconst pending = new WeakMap<Element, () => void>()\n\n/**\n * How many links a page prefetches on sight before the rest wait for\n * intent. A product page's payload is 30 KB with its related products,\n * and a listing shows twenty-four of them: on sight, every one, that was\n * three quarters of a megabyte per page on a phone. The first dozen in\n * view are fetched; a link past the budget is fetched on touch or on a\n * settled hover, a round trip before the click rather than before the\n * scroll. The budget is the page's, and starts again on each navigation.\n */\nconst ON_SIGHT_PER_PAGE = 12\nlet onSight = 0\nlet listening = false\n\nfunction budgetPerPage(): void {\n if (listening || typeof window === \"undefined\") return\n\n listening = true\n window.addEventListener(\"rsc-navigate\", () => {\n onSight = 0\n })\n}\n\nfunction savingData(): boolean {\n const connection = (navigator as { connection?: { saveData?: boolean } }).connection\n\n return connection?.saveData === true\n}\n\nfunction whenIdle(fn: () => void): void {\n const idle = (window as { requestIdleCallback?: (fn: () => void, opts?: { timeout: number }) => void }).requestIdleCallback\n\n if (idle) idle(fn, { timeout: 1000 })\n else setTimeout(fn, 50)\n}\n\nfunction observerFor(): IntersectionObserver | null {\n if (observer) return observer\n if (typeof IntersectionObserver === \"undefined\") return null\n\n observer = new IntersectionObserver(\n (entries) => {\n for (const entry of entries) {\n if (!entry.isIntersecting) continue\n\n const fire = pending.get(entry.target)\n\n observer!.unobserve(entry.target)\n pending.delete(entry.target)\n\n if (!fire) continue\n if (onSight >= ON_SIGHT_PER_PAGE) continue\n\n onSight++\n whenIdle(fire)\n }\n },\n // A little ahead of the fold: a link about to scroll into view is one\n // about to be tapped.\n { rootMargin: \"200px\" },\n )\n\n return observer\n}\n\n/**\n * Prefetch when the link is on screen. Returns the function that stops\n * watching; a no-op where this does not apply.\n */\nexport function prefetchWhenVisible(element: Element | null, fire: () => void): () => void {\n if (!element || savingData()) return () => {}\n\n budgetPerPage()\n\n const io = observerFor()\n\n if (!io) return () => {}\n\n pending.set(element, fire)\n io.observe(element)\n\n return () => {\n pending.delete(element)\n io.unobserve(element)\n }\n}\n"]}
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The head of a stored shell, corrected for the url it is served for.
3
+ *
4
+ * A shell for a route that listed no urls is one file for every url the
5
+ * route matches, so its <title> is whatever the build could know without a
6
+ * url: the layouts' metadata, once the page's own generateMetadata was seen
7
+ * to read the params and left out. The host serving it does know the url,
8
+ * and the page's metadata is a function of it - so the title and the
9
+ * description are written into the head here, as a string edit on the way
10
+ * out, before React resumes the holes below. The client's DocumentTitle
11
+ * sets the title again after hydration; this is for the tab before that,
12
+ * and for whoever reads the document without running it.
13
+ */
14
+ /**
15
+ * `shell` with the given title and description in its head. Only the two
16
+ * that every share preview and every tab reads; the rest of a page's
17
+ * metadata rides with the payload.
18
+ */
19
+ export declare function withHead(shell: string, metadata: {
20
+ title?: unknown;
21
+ description?: unknown;
22
+ } | null): string;