@rsc-kit/core 0.30.33 → 0.30.34

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.
@@ -1 +1 @@
1
- {"version":3,"file":"slotStore.js","sourceRoot":"","sources":["../../src/js/slotStore.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAeH,MAAM,KAAK,GAAG,IAAI,GAAG,EAAqB,CAAA;AAC1C,MAAM,SAAS,GAAG,IAAI,GAAG,EAAyB,CAAA;AAElD,kFAAkF;AAClF,MAAM,KAAK,GAAc,EAAE,IAAI,EAAE,SAAS,EAAE,CAAA;AAE5C,SAAS,MAAM,CAAC,IAAY;IAC1B,SAAS,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,QAAQ,EAAE,CAAC,CAAA;AACxD,CAAC;AAED,MAAM,UAAU,OAAO,CAAC,IAAY,EAAE,IAAU;IAC9C,KAAK,CAAC,GAAG,CAAC,IAAI,EAAE,EAAE,IAAI,EAAE,CAAC,CAAA;IACzB,MAAM,CAAC,IAAI,CAAC,CAAA;AACd,CAAC;AAED,MAAM,UAAU,eAAe,CAAC,IAAY,EAAE,QAAkB;IAC9D,MAAM,QAAQ,GAAG,SAAS,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,IAAI,GAAG,EAAY,CAAA;IAE3D,QAAQ,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAA;IACtB,SAAS,CAAC,GAAG,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAA;IAE7B,OAAO,GAAG,EAAE;QACV,QAAQ,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAA;IAC3B,CAAC,CAAA;AACH,CAAC;AAED,MAAM,UAAU,YAAY,CAAC,IAAY;IACvC,OAAO,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,KAAK,CAAA;AACjC,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,UAAU;IACxB,MAAM,KAAK,GAAG,CAAC,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC,CAAA;IAE/B,KAAK,CAAC,KAAK,EAAE,CAAA;IACb,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,CAAA;AACvB,CAAC","sourcesContent":["/**\n * What each parallel slot is showing, when the server has re-rendered one.\n *\n * A slot is the only region of a page smaller than the page that the server\n * can name, so it is the unit an action can invalidate on its own — two tables\n * refresh apart from each other only if they are two slots.\n *\n * Empty is meaningful, and is the ordinary case: a slot with nothing stored\n * renders what the server sent with the page. Nothing is retained here the way\n * segments retain pages; a slot has one current value and replacing it is the\n * whole point.\n */\n\ntype Tree = unknown\ntype Listener = () => void\n\n/**\n * Immutable, like the segment store and for the same reason:\n * useSyncExternalStore compares snapshots by identity, so returning a fresh\n * object per read reads as \"changed every render\" and loops until React gives\n * up. Every write replaces the entry; nothing is edited in place.\n */\ninterface SlotState {\n readonly tree: Tree\n}\n\nconst slots = new Map<string, SlotState>()\nconst listeners = new Map<string, Set<Listener>>()\n\n/** Shared by every slot that has never been written to, so identity is stable. */\nconst EMPTY: SlotState = { tree: undefined }\n\nfunction notify(name: string): void {\n listeners.get(name)?.forEach((listener) => listener())\n}\n\nexport function setSlot(name: string, tree: Tree): void {\n slots.set(name, { tree })\n notify(name)\n}\n\nexport function subscribeToSlot(name: string, listener: Listener): () => void {\n const existing = listeners.get(name) ?? new Set<Listener>()\n\n existing.add(listener)\n listeners.set(name, existing)\n\n return () => {\n existing.delete(listener)\n }\n}\n\nexport function getSlotState(name: string): SlotState {\n return slots.get(name) ?? EMPTY\n}\n\n/**\n * Forget every slot.\n *\n * A navigation replaces the page, and a slot rendered for the page you left\n * has no claim on the one you arrived at.\n */\nexport function clearSlots(): void {\n const names = [...slots.keys()]\n\n slots.clear()\n names.forEach(notify)\n}\n"]}
1
+ {"version":3,"file":"slotStore.js","sourceRoot":"","sources":["../../src/js/slotStore.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAeH,MAAM,KAAK,GAAG,IAAI,GAAG,EAAqB,CAAA;AAC1C,MAAM,SAAS,GAAG,IAAI,GAAG,EAAyB,CAAA;AAElD,kFAAkF;AAClF,MAAM,KAAK,GAAc,EAAE,IAAI,EAAE,SAAS,EAAE,CAAA;AAE5C,SAAS,MAAM,CAAC,IAAY;IAC1B,SAAS,CAAC,GAAG,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC,QAAQ,EAAE,CAAC,CAAA;AACxD,CAAC;AAED,MAAM,UAAU,OAAO,CAAC,IAAY,EAAE,IAAU;IAC9C,KAAK,CAAC,GAAG,CAAC,IAAI,EAAE,EAAE,IAAI,EAAE,CAAC,CAAA;IACzB,MAAM,CAAC,IAAI,CAAC,CAAA;AACd,CAAC;AAED,2EAA2E;AAC3E,MAAM,MAAM,GAAG,IAAI,GAAG,EAAkB,CAAA;AAExC,MAAM,UAAU,eAAe,CAAC,IAAY,EAAE,QAAkB,EAAE,KAAc;IAC9E,IAAI,KAAK,KAAK,SAAS;QAAE,MAAM,CAAC,GAAG,CAAC,IAAI,EAAE,KAAK,CAAC,CAAA;IAEhD,MAAM,QAAQ,GAAG,SAAS,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,IAAI,GAAG,EAAY,CAAA;IAE3D,QAAQ,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAA;IACtB,SAAS,CAAC,GAAG,CAAC,IAAI,EAAE,QAAQ,CAAC,CAAA;IAE7B,OAAO,GAAG,EAAE;QACV,QAAQ,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAA;IAC3B,CAAC,CAAA;AACH,CAAC;AAED,MAAM,UAAU,YAAY,CAAC,IAAY;IACvC,OAAO,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,KAAK,CAAA;AACjC,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,UAAU,CAAC,SAAS,GAAG,CAAC;IACtC,KAAK,MAAM,IAAI,IAAI,CAAC,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC,EAAE,CAAC;QACrC,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,SAAS,CAAC,GAAG,SAAS;YAAE,SAAQ;QAEzD,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC,CAAA;QAClB,MAAM,CAAC,IAAI,CAAC,CAAA;IACd,CAAC;AACH,CAAC","sourcesContent":["/**\n * What each parallel slot is showing, when the server has re-rendered one.\n *\n * A slot is the only region of a page smaller than the page that the server\n * can name, so it is the unit an action can invalidate on its own — two tables\n * refresh apart from each other only if they are two slots.\n *\n * Empty is meaningful, and is the ordinary case: a slot with nothing stored\n * renders what the server sent with the page. Nothing is retained here the way\n * segments retain pages; a slot has one current value and replacing it is the\n * whole point.\n */\n\ntype Tree = unknown\ntype Listener = () => void\n\n/**\n * Immutable, like the segment store and for the same reason:\n * useSyncExternalStore compares snapshots by identity, so returning a fresh\n * object per read reads as \"changed every render\" and loops until React gives\n * up. Every write replaces the entry; nothing is edited in place.\n */\ninterface SlotState {\n readonly tree: Tree\n}\n\nconst slots = new Map<string, SlotState>()\nconst listeners = new Map<string, Set<Listener>>()\n\n/** Shared by every slot that has never been written to, so identity is stable. */\nconst EMPTY: SlotState = { tree: undefined }\n\nfunction notify(name: string): void {\n listeners.get(name)?.forEach((listener) => listener())\n}\n\nexport function setSlot(name: string, tree: Tree): void {\n slots.set(name, { tree })\n notify(name)\n}\n\n/** The depth of the segment each slot renders in, told by its boundary. */\nconst depths = new Map<string, number>()\n\nexport function subscribeToSlot(name: string, listener: Listener, depth?: number): () => void {\n if (depth !== undefined) depths.set(name, depth)\n\n const existing = listeners.get(name) ?? new Set<Listener>()\n\n existing.add(listener)\n listeners.set(name, existing)\n\n return () => {\n existing.delete(listener)\n }\n}\n\nexport function getSlotState(name: string): SlotState {\n return slots.get(name) ?? EMPTY\n}\n\n/**\n * Forget the slots a navigation replaces.\n *\n * A navigation answered at a depth renders everything from that boundary\n * down, and a slot rendered for the page you left has no claim on the one you\n * arrived at. A slot of a layout above it is not part of the answer: that\n * layout stays mounted, and what an action last put in its slot is still\n * current. Emptied, the boundary fell back to the layout's first render, and\n * a team picker went back to the name from before the rename.\n *\n * With no depth, every slot - and a slot whose boundary never said where it\n * is goes either way.\n */\nexport function clearSlots(fromDepth = 0): void {\n for (const name of [...slots.keys()]) {\n if ((depths.get(name) ?? fromDepth) < fromDepth) continue\n\n slots.delete(name)\n notify(name)\n }\n}\n"]}
@@ -21,7 +21,15 @@
21
21
  * One observer for every link, and the work is done when the browser is
22
22
  * idle - a list scrolled into view is many links at once, and the browser's
23
23
  * per-origin connections should not all be taken by pages nobody has tapped
24
- * yet. Each link prefetches once; the router keeps the payload for its TTL.
24
+ * yet.
25
+ *
26
+ * A link asks again each time it comes back into view, and when the tab is
27
+ * visible again. The router keeps a payload for its TTL and ignores the ask
28
+ * until then, so this is a request only for one that expired: a visitor who
29
+ * read for a minute and scrolled back to the nav, or came back to the tab,
30
+ * tapped a link whose payload was thirty seconds gone and waited a round trip
31
+ * for it. Not on a timer: a page left open would refetch every link on it,
32
+ * every thirty seconds, for nobody.
25
33
  */
26
34
  /**
27
35
  * Prefetch when the link is on screen. Returns the function that stops
@@ -21,10 +21,22 @@
21
21
  * One observer for every link, and the work is done when the browser is
22
22
  * idle - a list scrolled into view is many links at once, and the browser's
23
23
  * per-origin connections should not all be taken by pages nobody has tapped
24
- * yet. Each link prefetches once; the router keeps the payload for its TTL.
24
+ * yet.
25
+ *
26
+ * A link asks again each time it comes back into view, and when the tab is
27
+ * visible again. The router keeps a payload for its TTL and ignores the ask
28
+ * until then, so this is a request only for one that expired: a visitor who
29
+ * read for a minute and scrolled back to the nav, or came back to the tab,
30
+ * tapped a link whose payload was thirty seconds gone and waited a round trip
31
+ * for it. Not on a timer: a page left open would refetch every link on it,
32
+ * every thirty seconds, for nobody.
25
33
  */
26
34
  let observer = null;
27
- const pending = new WeakMap();
35
+ const fires = new WeakMap();
36
+ /** Links that have spent the page's budget, which asking again does not spend twice. */
37
+ const counted = new WeakSet();
38
+ /** On screen now, for the tab coming back. */
39
+ const inView = new Set();
28
40
  /**
29
41
  * How many links a page prefetches on sight before the rest wait for
30
42
  * intent. A product page's payload is 30 KB with its related products,
@@ -44,6 +56,15 @@ function budgetPerPage() {
44
56
  window.addEventListener("rsc-navigate", () => {
45
57
  onSight = 0;
46
58
  });
59
+ document.addEventListener("visibilitychange", () => {
60
+ if (document.visibilityState !== "visible")
61
+ return;
62
+ for (const element of inView) {
63
+ const fire = fires.get(element);
64
+ if (fire && counted.has(element))
65
+ whenIdle(fire);
66
+ }
67
+ });
47
68
  }
48
69
  function savingData() {
49
70
  const connection = navigator.connection;
@@ -62,17 +83,21 @@ function observerFor() {
62
83
  if (typeof IntersectionObserver === "undefined")
63
84
  return null;
64
85
  observer = new IntersectionObserver((entries) => {
65
- for (const entry of entries) {
66
- if (!entry.isIntersecting)
86
+ for (const { isIntersecting, target } of entries) {
87
+ if (!isIntersecting) {
88
+ inView.delete(target);
67
89
  continue;
68
- const fire = pending.get(entry.target);
69
- observer.unobserve(entry.target);
70
- pending.delete(entry.target);
90
+ }
91
+ inView.add(target);
92
+ const fire = fires.get(target);
71
93
  if (!fire)
72
94
  continue;
73
- if (onSight >= ON_SIGHT_PER_PAGE)
74
- continue;
75
- onSight++;
95
+ if (!counted.has(target)) {
96
+ if (onSight >= ON_SIGHT_PER_PAGE)
97
+ continue;
98
+ onSight++;
99
+ counted.add(target);
100
+ }
76
101
  whenIdle(fire);
77
102
  }
78
103
  },
@@ -92,10 +117,11 @@ export function prefetchWhenVisible(element, fire) {
92
117
  const io = observerFor();
93
118
  if (!io)
94
119
  return () => { };
95
- pending.set(element, fire);
120
+ fires.set(element, fire);
96
121
  io.observe(element);
97
122
  return () => {
98
- pending.delete(element);
123
+ fires.delete(element);
124
+ inView.delete(element);
99
125
  io.unobserve(element);
100
126
  };
101
127
  }
@@ -1 +1 @@
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"]}
1
+ {"version":3,"file":"viewportPrefetch.js","sourceRoot":"","sources":["../../src/js/viewportPrefetch.ts"],"names":[],"mappings":"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","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.\n *\n * A link asks again each time it comes back into view, and when the tab is\n * visible again. The router keeps a payload for its TTL and ignores the ask\n * until then, so this is a request only for one that expired: a visitor who\n * read for a minute and scrolled back to the nav, or came back to the tab,\n * tapped a link whose payload was thirty seconds gone and waited a round trip\n * for it. Not on a timer: a page left open would refetch every link on it,\n * every thirty seconds, for nobody.\n */\n\nlet observer: IntersectionObserver | null = null\nconst fires = new WeakMap<Element, () => void>()\n/** Links that have spent the page's budget, which asking again does not spend twice. */\nconst counted = new WeakSet<Element>()\n/** On screen now, for the tab coming back. */\nconst inView = new Set<Element>()\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 document.addEventListener(\"visibilitychange\", () => {\n if (document.visibilityState !== \"visible\") return\n\n for (const element of inView) {\n const fire = fires.get(element)\n\n if (fire && counted.has(element)) whenIdle(fire)\n }\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 { isIntersecting, target } of entries) {\n if (!isIntersecting) {\n inView.delete(target)\n continue\n }\n\n inView.add(target)\n\n const fire = fires.get(target)\n\n if (!fire) continue\n\n if (!counted.has(target)) {\n if (onSight >= ON_SIGHT_PER_PAGE) continue\n\n onSight++\n counted.add(target)\n }\n\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 fires.set(element, fire)\n io.observe(element)\n\n return () => {\n fires.delete(element)\n inView.delete(element)\n io.unobserve(element)\n }\n}\n"]}
package/dist/vite.js CHANGED
@@ -35,7 +35,7 @@ import { ownHosts } from "./hostRouting.js";
35
35
  import { unrollBarrelImports } from "./barrelImports.js";
36
36
  import { hasUseSsr, serverRendererMessage, SERVER_RENDERER, ssrProxyModule, UseSsrError, withoutSsrDirective } from "./useSsr.js";
37
37
  import { httpHostCalls } from "./hostCalls.js";
38
- import { COMMANDS_MODULE_ID, commandFiles, commandsEntry, commandsEntrySource, commandsModule, compileEntryWithCommands } from "./commandFiles.js";
38
+ import { BARE_COMMANDS_ENVIRONMENT, BARE_COMMANDS_MODULE_ID, COMMANDS_MODULE_ID, bareCommandsModule, commandFiles, commandsEntry, commandsEntrySource, commandsModule, compileEntryWithCommands } from "./commandFiles.js";
39
39
  import { clientPackages, importsServerRenderer, packageDir as installedPackageDir, packageEntryInGraph, polyfillsToTrace, traceableByName } from "./clientPackages.js";
40
40
  // Resolved once per rscKit() call. One build runs in one process, so these are
41
41
  // module state rather than threaded through every helper.
@@ -50,6 +50,8 @@ let clientLibraryImports = [];
50
50
  * decisions buildApp makes about where stored pages can live.
51
51
  */
52
52
  let nitroPreset = null;
53
+ /** Set by Nitro's setup of a built server, which asks for the commands before Vite has resolved. */
54
+ let nitroBuilding = false;
53
55
  /** `src/commands`, read once per build; null until asked. See buildCommands(). */
54
56
  let commandsFound = null;
55
57
  /**
@@ -85,7 +87,7 @@ const COMMAND_PRESETS = /^(?:bun|node|node[-_]server|node[-_]cluster)$/;
85
87
  * seen.
86
88
  */
87
89
  function buildCommands() {
88
- if (resolvedConfig?.command !== "build")
90
+ if (resolvedConfig?.command !== "build" && !nitroBuilding)
89
91
  return [];
90
92
  if (commandsFound)
91
93
  return commandsFound;
@@ -2704,12 +2706,14 @@ function commandsPlugin() {
2704
2706
  return {
2705
2707
  name: "rsc-kit:commands",
2706
2708
  resolveId(id) {
2707
- if (id === COMMANDS_MODULE_ID)
2709
+ if (id === COMMANDS_MODULE_ID || id === BARE_COMMANDS_MODULE_ID)
2708
2710
  return "\0" + id;
2709
2711
  },
2710
2712
  load(id) {
2711
2713
  if (id === "\0" + COMMANDS_MODULE_ID)
2712
2714
  return commandsModule(buildCommands());
2715
+ if (id === "\0" + BARE_COMMANDS_MODULE_ID)
2716
+ return bareCommandsModule(buildCommands(), join(packageDir, "commands"));
2713
2717
  },
2714
2718
  };
2715
2719
  }
@@ -7506,6 +7510,30 @@ export function rscKit(options = {}) {
7506
7510
  if (nitro.options.dev)
7507
7511
  return;
7508
7512
  nitroPreset = nitro.options.preset ?? null;
7513
+ nitroBuilding = true;
7514
+ // The commands that did not ask for register() are an entry of the
7515
+ // rsc environment that Nitro would leave behind: it bundles the
7516
+ // server from the environment's main entry only. Given to it as an
7517
+ // entry of the server bundle, it is written beside the server's
7518
+ // `_ssr/rsc.mjs` and shares its chunks - without importing the entry
7519
+ // that imports instrumentation.ts.
7520
+ nitro.hooks.hook("rollup:before", (_nitro, config) => {
7521
+ if (!buildCommands().some((command) => !command.instrumented))
7522
+ return;
7523
+ const file = join(nitro.options.buildDir, "vite", "services", "rsc", BARE_COMMANDS_ENVIRONMENT + ".js");
7524
+ const input = config.input;
7525
+ const names = config.output?.entryFileNames;
7526
+ config.input = {
7527
+ ...(typeof input === "string" ? { index: input } : Array.isArray(input) ? Object.fromEntries(input.map((entry, i) => [i ? "index" + i : "index", entry])) : input),
7528
+ [BARE_COMMANDS_ENVIRONMENT]: file,
7529
+ };
7530
+ config.output = {
7531
+ ...config.output,
7532
+ entryFileNames: (chunk) => chunk.facadeModuleId === file
7533
+ ? "_ssr/" + BARE_COMMANDS_ENVIRONMENT + ".mjs"
7534
+ : typeof names === "function" ? names(chunk) : (names ?? "index.mjs"),
7535
+ };
7536
+ });
7509
7537
  // The entry a command runs from, beside the server it does not start.
7510
7538
  // Written once Nitro has assembled the output, because it names a
7511
7539
  // file Nitro places: if that moves, the build says so rather than a
@@ -7518,6 +7546,10 @@ export function rscKit(options = {}) {
7518
7546
  throw new Error(`[rsc-kit] src/commands runs from ${join(serverDir, "_ssr", "rsc.mjs")}, and Nitro did not write it there. ` +
7519
7547
  "Nitro's output layout changed; the commands entry needs to follow it.");
7520
7548
  }
7549
+ if (buildCommands().some((command) => !command.instrumented) && !existsSync(join(serverDir, "_ssr", BARE_COMMANDS_ENVIRONMENT + ".mjs"))) {
7550
+ throw new Error(`[rsc-kit] The commands that do not ask for register() run from ${join(serverDir, "_ssr", BARE_COMMANDS_ENVIRONMENT + ".mjs")}, and Nitro did not write it there. ` +
7551
+ "Nitro's output layout changed; the commands entry needs to follow it.");
7552
+ }
7521
7553
  writeFileSync(join(serverDir, "commands.mjs"), commandsEntrySource(commandEntryOptions()));
7522
7554
  });
7523
7555
  // A binary carries what the server would read from node_modules. Once
@@ -7890,7 +7922,16 @@ export function rscKit(options = {}) {
7890
7922
  rsc: {
7891
7923
  build: {
7892
7924
  rollupOptions: {
7893
- input: { index: join(genDir, "entry.rsc.tsx") },
7925
+ input: {
7926
+ index: join(genDir, "entry.rsc.tsx"),
7927
+ // The commands that did not ask for register(), as an entry
7928
+ // of their own beside the server's, so that what they share
7929
+ // with it is split out and instrumentation.ts - reached
7930
+ // only from the server's entry - stays in that one.
7931
+ ...(env.command === "build" && existsSync(join(sourceDir, "commands"))
7932
+ ? { [BARE_COMMANDS_ENVIRONMENT]: BARE_COMMANDS_MODULE_ID }
7933
+ : {}),
7934
+ },
7894
7935
  external: serverExternals,
7895
7936
  // The server-references map is `id: () => import(file)` for
7896
7937
  // every "use server" file, and the page that passes an action