@decocms/tanstack 7.77.3 → 7.77.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/tanstack",
3
- "version": "7.77.3",
3
+ "version": "7.77.5",
4
4
  "type": "module",
5
5
  "description": "Deco framework binding for TanStack Start + Cloudflare Workers",
6
6
  "repository": {
@@ -27,9 +27,9 @@
27
27
  "lint:unused": "knip"
28
28
  },
29
29
  "dependencies": {
30
- "@decocms/blocks": "7.77.3",
31
- "@decocms/blocks-admin": "7.77.3",
32
- "@decocms/blocks-cli": "7.77.3",
30
+ "@decocms/blocks": "7.77.5",
31
+ "@decocms/blocks-admin": "7.77.5",
32
+ "@decocms/blocks-cli": "7.77.5",
33
33
  "@deco-cx/warp-node": "^0.3.16",
34
34
  "fast-json-patch": "^3.1.0",
35
35
  "ws": "^8.18.0"
@@ -247,6 +247,34 @@ interface DeferredSectionWrapperProps {
247
247
  }) => Promise<ResolvedSection | null>;
248
248
  }
249
249
 
250
+ /**
251
+ * Whether an observation should trigger the deferred load.
252
+ *
253
+ * `IntersectionObserver` only fires on ENTERING. A section the reader is
254
+ * already past when the observer attaches gets exactly one callback, with
255
+ * `isIntersecting: false`, and never fires again while the reader keeps
256
+ * scrolling down — so it stays a skeleton for the rest of the visit. That is
257
+ * reachable on every load that does not start at the top: browser scroll
258
+ * restoration on reload, back/forward, an `#anchor`, or a programmatic
259
+ * `scrollTo`.
260
+ *
261
+ * `bottom < 0` means the element sits entirely above the viewport. It cannot
262
+ * collide with the thundering herd the frame gate in the observer effect
263
+ * guards against: that herd is sections BELOW a stale scroll offset, and by
264
+ * the time the observer attaches the router has reset scroll to the top,
265
+ * where nothing is above the viewport.
266
+ *
267
+ * Exported for tests — jsdom has no layout and never fires a real
268
+ * `IntersectionObserver`, so the interesting case cannot be produced by
269
+ * rendering the component.
270
+ */
271
+ export function shouldLoadOnObservation(entry: {
272
+ isIntersecting: boolean;
273
+ boundingClientRect: { bottom: number };
274
+ }): boolean {
275
+ return entry.isIntersecting || entry.boundingClientRect.bottom < 0;
276
+ }
277
+
250
278
  function DeferredSectionWrapper({
251
279
  deferred,
252
280
  pagePath,
@@ -346,7 +374,8 @@ function DeferredSectionWrapper({
346
374
  if (triggered.current) return;
347
375
  observer = new IntersectionObserver(
348
376
  ([entry]) => {
349
- if (entry?.isIntersecting && !triggered.current) {
377
+ if (!entry || triggered.current) return;
378
+ if (shouldLoadOnObservation(entry)) {
350
379
  observer?.disconnect();
351
380
  load();
352
381
  }
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { shouldLoadOnObservation } from "./DecoPageRenderer";
4
+
5
+ /**
6
+ * Guards the "reader is already past the section" branch of
7
+ * `DeferredSectionWrapper`'s IntersectionObserver callback.
8
+ *
9
+ * `IntersectionObserver` fires on ENTERING. The observer is attached one frame
10
+ * after hydration (see `deferredObserverTiming.test.tsx` for why), and at that
11
+ * moment the reader is not necessarily at the top of the document: browser
12
+ * scroll restoration on reload, back/forward, an `#anchor`, or a programmatic
13
+ * `scrollTo` all land below the fold. A deferred section left above the
14
+ * viewport then receives exactly one callback with `isIntersecting: false` and
15
+ * never fires again while the reader keeps going down — it stays a skeleton
16
+ * for the whole visit.
17
+ *
18
+ * Measured on a storefront `/prime` before the fix: landing at `y=5200` with
19
+ * the product shelf at 3164–3829 left the shelf empty at 8s, 43s and 83s, and
20
+ * it only filled after scrolling back up into it.
21
+ *
22
+ * jsdom has no layout and never fires a real IntersectionObserver, so this
23
+ * asserts the decision directly rather than rendering the component.
24
+ */
25
+ const rect = (bottom: number) => ({ bottom });
26
+
27
+ describe("deferred observer — section already scrolled past", () => {
28
+ it("loads a section left above the viewport", () => {
29
+ // Reader reloaded below it; the element is entirely above the fold.
30
+ expect(shouldLoadOnObservation({ isIntersecting: false, boundingClientRect: rect(-400) })).toBe(
31
+ true,
32
+ );
33
+ });
34
+
35
+ it("loads a section in view", () => {
36
+ expect(shouldLoadOnObservation({ isIntersecting: true, boundingClientRect: rect(500) })).toBe(
37
+ true,
38
+ );
39
+ });
40
+
41
+ it("does NOT load a section still below the viewport", () => {
42
+ // The normal case deferral exists for — must keep waiting for the scroll,
43
+ // otherwise every below-the-fold section fires its POST on mount.
44
+ expect(shouldLoadOnObservation({ isIntersecting: false, boundingClientRect: rect(2400) })).toBe(
45
+ false,
46
+ );
47
+ });
48
+
49
+ it("does NOT load a section sitting exactly at the viewport top", () => {
50
+ // bottom === 0 is the boundary: nothing of it is visible yet, but it has
51
+ // not been passed either. `rootMargin: 300px` means the real observer
52
+ // already reports this one as intersecting, so the predicate must not
53
+ // double-count it as passed.
54
+ expect(shouldLoadOnObservation({ isIntersecting: false, boundingClientRect: rect(0) })).toBe(
55
+ false,
56
+ );
57
+ });
58
+ });