@decocms/tanstack 7.77.4 → 7.77.6
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
|
+
"version": "7.77.6",
|
|
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.
|
|
31
|
-
"@decocms/blocks-admin": "7.77.
|
|
32
|
-
"@decocms/blocks-cli": "7.77.
|
|
30
|
+
"@decocms/blocks": "7.77.6",
|
|
31
|
+
"@decocms/blocks-admin": "7.77.6",
|
|
32
|
+
"@decocms/blocks-cli": "7.77.6",
|
|
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
|
|
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
|
+
});
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
DEFAULT_SECURITY_HEADERS,
|
|
16
16
|
detectLocationMatcher,
|
|
17
17
|
injectGeoCookies,
|
|
18
|
+
setCookieIdentity,
|
|
18
19
|
} from "./workerEntry";
|
|
19
20
|
|
|
20
21
|
const EMPTY_ENV = {};
|
|
@@ -1031,3 +1032,53 @@ describe("fast-deploy hydration ordering", () => {
|
|
|
1031
1032
|
}
|
|
1032
1033
|
});
|
|
1033
1034
|
});
|
|
1035
|
+
|
|
1036
|
+
describe("Set-Cookie deduplication keys on name + Domain + Path", () => {
|
|
1037
|
+
it("setCookieIdentity: same name, different scope → different identities", () => {
|
|
1038
|
+
expect(setCookieIdentity("a=1; Path=/")).not.toBe(
|
|
1039
|
+
setCookieIdentity("a=1; Path=/; Domain=example.com"),
|
|
1040
|
+
);
|
|
1041
|
+
expect(setCookieIdentity("a=1; Path=/; Domain=www.example.com")).not.toBe(
|
|
1042
|
+
setCookieIdentity("a=1; Path=/; Domain=example.com"),
|
|
1043
|
+
);
|
|
1044
|
+
expect(setCookieIdentity("a=1; Path=/")).not.toBe(setCookieIdentity("a=1; Path=/account"));
|
|
1045
|
+
});
|
|
1046
|
+
|
|
1047
|
+
it("setCookieIdentity: Domain is case-insensitive, a leading dot is ignored, value is not part of the key", () => {
|
|
1048
|
+
expect(setCookieIdentity("a=1; Path=/; Domain=Example.COM")).toBe(
|
|
1049
|
+
setCookieIdentity("a=2; path=/; domain=.example.com; Secure"),
|
|
1050
|
+
);
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
it("keeps every scope of a multi-scope clear (sign-out) and still collapses true duplicates", async () => {
|
|
1054
|
+
setBlocks({});
|
|
1055
|
+
const clears = [
|
|
1056
|
+
"VtexIdclientAutCookie=; Path=/; Max-Age=0",
|
|
1057
|
+
"VtexIdclientAutCookie=; Path=/; Max-Age=0; Domain=www.example.com",
|
|
1058
|
+
"VtexIdclientAutCookie=; Path=/; Max-Age=0; Domain=example.com",
|
|
1059
|
+
];
|
|
1060
|
+
const entry = {
|
|
1061
|
+
fetch: async () => {
|
|
1062
|
+
const headers = new Headers({ "content-type": "text/plain" });
|
|
1063
|
+
// A true duplicate (same name, same scope): only the last one survives.
|
|
1064
|
+
headers.append("set-cookie", "checkout.vtex.com=__ofid=old; Path=/");
|
|
1065
|
+
for (const sc of clears) headers.append("set-cookie", sc);
|
|
1066
|
+
headers.append("set-cookie", "checkout.vtex.com=__ofid=new; Path=/");
|
|
1067
|
+
return new Response("ok", { status: 200, headers });
|
|
1068
|
+
},
|
|
1069
|
+
};
|
|
1070
|
+
const worker = createDecoWorkerEntry(entry, { observability: false });
|
|
1071
|
+
const res = await worker.fetch(
|
|
1072
|
+
new Request("https://www.example.com/telesales/sign-out"),
|
|
1073
|
+
EMPTY_ENV,
|
|
1074
|
+
MOCK_CTX,
|
|
1075
|
+
);
|
|
1076
|
+
const out = (res.headers as unknown as { getSetCookie: () => string[] }).getSetCookie();
|
|
1077
|
+
// Order is not part of the contract; the set of surviving headers is.
|
|
1078
|
+
expect(out).toHaveLength(4);
|
|
1079
|
+
expect(out).toEqual(
|
|
1080
|
+
expect.arrayContaining([...clears, "checkout.vtex.com=__ofid=new; Path=/"]),
|
|
1081
|
+
);
|
|
1082
|
+
expect(out).not.toContain("checkout.vtex.com=__ofid=old; Path=/");
|
|
1083
|
+
});
|
|
1084
|
+
});
|
package/src/sdk/workerEntry.ts
CHANGED
|
@@ -930,20 +930,45 @@ function stripSafeCookiesForCache(response: Response, safeCookieSet: Set<string>
|
|
|
930
930
|
}
|
|
931
931
|
|
|
932
932
|
/**
|
|
933
|
-
*
|
|
934
|
-
*
|
|
935
|
-
*
|
|
933
|
+
* Identity of a `Set-Cookie` header for deduplication: the browser keys its
|
|
934
|
+
* cookie jar by name + Domain + Path (RFC 6265 §5.3 step 11), so two headers
|
|
935
|
+
* that differ in scope address two different cookies — a clear for the
|
|
936
|
+
* host-only cookie does not touch the `Domain=example.com` one. The domain is
|
|
937
|
+
* case-insensitive and a leading dot is ignored (§5.2.3); the path is
|
|
938
|
+
* compared as sent.
|
|
939
|
+
*/
|
|
940
|
+
export function setCookieIdentity(setCookie: string): string {
|
|
941
|
+
const [nameValue = "", ...attrs] = setCookie.split(";");
|
|
942
|
+
const eqIdx = nameValue.indexOf("=");
|
|
943
|
+
const name = eqIdx > 0 ? nameValue.slice(0, eqIdx).trim() : nameValue.trim();
|
|
944
|
+
let domain = "";
|
|
945
|
+
let path = "";
|
|
946
|
+
for (const attr of attrs) {
|
|
947
|
+
const sep = attr.indexOf("=");
|
|
948
|
+
const key = (sep >= 0 ? attr.slice(0, sep) : attr).trim().toLowerCase();
|
|
949
|
+
const value = sep >= 0 ? attr.slice(sep + 1).trim() : "";
|
|
950
|
+
if (key === "domain") domain = value.replace(/^\./, "").toLowerCase();
|
|
951
|
+
else if (key === "path") path = value;
|
|
952
|
+
}
|
|
953
|
+
return `${name}\u0000${domain}\u0000${path}`;
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/**
|
|
957
|
+
* Deduplicate Set-Cookie headers — keep only the LAST occurrence of each
|
|
958
|
+
* cookie identity (name + Domain + Path, see {@link setCookieIdentity}).
|
|
959
|
+
* Multiple layers (VTEX middleware, invoke handlers, etc.) may independently
|
|
960
|
+
* append the same cookie. Keying by name alone collapsed deliberate clears of
|
|
961
|
+
* the same cookie under different scopes (host-only / `Domain=host` /
|
|
962
|
+
* `Domain=parent`) into whichever came last, leaving the other scopes alive.
|
|
936
963
|
*/
|
|
937
964
|
function deduplicateSetCookies(response: Response): void {
|
|
938
965
|
const setCookies = (response.headers as any).getSetCookie?.() as string[] | undefined;
|
|
939
966
|
if (!setCookies || setCookies.length <= 1) return;
|
|
940
967
|
|
|
941
|
-
// Build map: cookie
|
|
968
|
+
// Build map: cookie identity → last Set-Cookie value
|
|
942
969
|
const seen = new Map<string, string>();
|
|
943
970
|
for (const sc of setCookies) {
|
|
944
|
-
|
|
945
|
-
const name = eqIdx > 0 ? sc.slice(0, eqIdx).trim() : sc;
|
|
946
|
-
seen.set(name, sc);
|
|
971
|
+
seen.set(setCookieIdentity(sc), sc);
|
|
947
972
|
}
|
|
948
973
|
|
|
949
974
|
// If no duplicates, nothing to do
|