@decocms/blocks 7.32.1 → 7.33.0

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/blocks",
3
- "version": "7.32.1",
3
+ "version": "7.33.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -15,6 +15,7 @@ export {
15
15
  type FitOptions,
16
16
  } from "./Image";
17
17
  export { Picture, Source, type PictureProps, type SourceProps } from "./Picture";
18
+ export { useLoadMore } from "./useLoadMore";
18
19
  export {
19
20
  ProductJsonLd,
20
21
  PLPJsonLd,
@@ -0,0 +1,205 @@
1
+ import { createElement } from "react";
2
+ import { act } from "react";
3
+ import { createRoot, type Root } from "react-dom/client";
4
+ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
5
+ import { useLoadMore, getNextPageUrl } from "./useLoadMore";
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // Minimal hook runner for jsdom (no @testing-library/react dependency)
9
+ // ---------------------------------------------------------------------------
10
+
11
+ function makeHookRunner<T>(useHookFn: () => T) {
12
+ let value!: T;
13
+ let root!: Root;
14
+ const container = document.createElement("div");
15
+ document.body.appendChild(container);
16
+
17
+ const Wrapper = () => {
18
+ value = useHookFn();
19
+ return null;
20
+ };
21
+
22
+ return {
23
+ async mount() {
24
+ await act(async () => {
25
+ root = createRoot(container);
26
+ root.render(createElement(Wrapper));
27
+ });
28
+ return value;
29
+ },
30
+ get current(): T {
31
+ return value;
32
+ },
33
+ async rerender() {
34
+ await act(async () => {
35
+ root.render(createElement(Wrapper));
36
+ });
37
+ return value;
38
+ },
39
+ async unmount() {
40
+ await act(async () => root.unmount());
41
+ container.remove();
42
+ },
43
+ };
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Test data
48
+ // ---------------------------------------------------------------------------
49
+
50
+ const PAGE1 = { products: ["a"], pageInfo: { nextPage: "/s?page=2" } };
51
+ const PAGE2 = { products: ["b"], pageInfo: { nextPage: undefined } };
52
+ const FLAT_NEXT = { products: ["c"], nextPage: "/s?page=2" };
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // getNextPageUrl (pure helper)
56
+ // ---------------------------------------------------------------------------
57
+
58
+ describe("getNextPageUrl", () => {
59
+ it("reads pageInfo.nextPage", () => {
60
+ expect(getNextPageUrl(PAGE1)).toBe("/s?page=2");
61
+ });
62
+
63
+ it("returns undefined when pageInfo.nextPage is absent", () => {
64
+ expect(getNextPageUrl(PAGE2)).toBeUndefined();
65
+ });
66
+
67
+ it("falls back to flat nextPage", () => {
68
+ expect(getNextPageUrl(FLAT_NEXT)).toBe("/s?page=2");
69
+ });
70
+
71
+ it("returns undefined for non-objects", () => {
72
+ expect(getNextPageUrl(null)).toBeUndefined();
73
+ expect(getNextPageUrl("string")).toBeUndefined();
74
+ });
75
+ });
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // useLoadMore (actual hook via React render)
79
+ // ---------------------------------------------------------------------------
80
+
81
+ describe("useLoadMore", () => {
82
+ beforeEach(() => {
83
+ vi.stubGlobal("location", { href: "https://site.com/s?q=telha" });
84
+ });
85
+ afterEach(() => {
86
+ vi.unstubAllGlobals();
87
+ });
88
+
89
+ it("accumulates pages and flips hasMore off on last page", async () => {
90
+ vi.stubGlobal(
91
+ "fetch",
92
+ vi.fn(() => Promise.resolve({ ok: true, json: async () => PAGE2 })),
93
+ );
94
+
95
+ const runner = makeHookRunner(() =>
96
+ useLoadMore(PAGE1, "apps/vtex.ts/loaders/plp.ts"),
97
+ );
98
+ await runner.mount();
99
+
100
+ expect(runner.current.pages).toEqual([PAGE1]);
101
+ expect(runner.current.hasMore).toBe(true);
102
+
103
+ await act(async () => {
104
+ await runner.current.loadMore();
105
+ });
106
+
107
+ expect(runner.current.pages).toEqual([PAGE1, PAGE2]);
108
+ expect(runner.current.hasMore).toBe(false);
109
+ await runner.unmount();
110
+ });
111
+
112
+ it("calls /deco/invoke with absolute __pageUrl and __pagePath", async () => {
113
+ const captured: string[] = [];
114
+ vi.stubGlobal(
115
+ "fetch",
116
+ vi.fn((_url: string, init: RequestInit) => {
117
+ captured.push(init.body as string);
118
+ return Promise.resolve({ ok: true, json: async () => PAGE2 });
119
+ }),
120
+ );
121
+
122
+ const runner = makeHookRunner(() =>
123
+ useLoadMore(PAGE1, "apps/vtex.ts/loaders/plp.ts"),
124
+ );
125
+ await runner.mount();
126
+ await act(async () => { await runner.current.loadMore(); });
127
+
128
+ const body = JSON.parse(captured[0]);
129
+ expect(body.props.__pageUrl).toBe("https://site.com/s?page=2");
130
+ expect(body.props.__pagePath).toBe("/s");
131
+ await runner.unmount();
132
+ });
133
+
134
+ it("sets error state on failed invoke, does not throw", async () => {
135
+ vi.stubGlobal(
136
+ "fetch",
137
+ vi.fn(() => Promise.resolve({ ok: false, status: 500 })),
138
+ );
139
+
140
+ const runner = makeHookRunner(() =>
141
+ useLoadMore(PAGE1, "apps/vtex.ts/loaders/plp.ts"),
142
+ );
143
+ await runner.mount();
144
+ await act(async () => { await runner.current.loadMore(); });
145
+
146
+ expect(runner.current.error).toBeInstanceOf(Error);
147
+ expect(runner.current.error?.message).toContain("500");
148
+ // pages unchanged
149
+ expect(runner.current.pages).toEqual([PAGE1]);
150
+ await runner.unmount();
151
+ });
152
+
153
+ it("resets pages when resetKey changes", async () => {
154
+ vi.stubGlobal(
155
+ "fetch",
156
+ vi.fn(() => Promise.resolve({ ok: true, json: async () => PAGE2 })),
157
+ );
158
+
159
+ let resetKey = "url-a";
160
+ const runner = makeHookRunner(() =>
161
+ useLoadMore(PAGE1, "apps/vtex.ts/loaders/plp.ts", resetKey),
162
+ );
163
+ await runner.mount();
164
+
165
+ // accumulate one page
166
+ await act(async () => { await runner.current.loadMore(); });
167
+ expect(runner.current.pages).toHaveLength(2);
168
+
169
+ // simulate filter change: resetKey changes, initial stays PAGE1 for simplicity
170
+ resetKey = "url-b";
171
+ await runner.rerender();
172
+
173
+ expect(runner.current.pages).toEqual([PAGE1]);
174
+ expect(runner.current.hasMore).toBe(true);
175
+ await runner.unmount();
176
+ });
177
+
178
+ it("prevents double-submission (inflight guard)", async () => {
179
+ let resolveFirst!: () => void;
180
+ vi.stubGlobal(
181
+ "fetch",
182
+ vi.fn(
183
+ () =>
184
+ new Promise<{ ok: boolean; json: () => Promise<typeof PAGE2> }>((resolve) => {
185
+ resolveFirst = () => resolve({ ok: true, json: async () => PAGE2 });
186
+ }),
187
+ ),
188
+ );
189
+
190
+ const runner = makeHookRunner(() =>
191
+ useLoadMore(PAGE1, "apps/vtex.ts/loaders/plp.ts"),
192
+ );
193
+ await runner.mount();
194
+
195
+ // fire two rapid clicks (no await between them)
196
+ const p1 = act(async () => { runner.current.loadMore(); });
197
+ const p2 = act(async () => { runner.current.loadMore(); });
198
+ resolveFirst();
199
+ await Promise.all([p1, p2]);
200
+
201
+ // fetch must be called only once
202
+ expect(vi.mocked(fetch)).toHaveBeenCalledTimes(1);
203
+ await runner.unmount();
204
+ });
205
+ });
@@ -0,0 +1,98 @@
1
+ import { useState, useEffect, useCallback, useRef } from "react";
2
+
3
+ /**
4
+ * Extract the next-page URL from a loader result.
5
+ * Handles both conventions used by commerce loaders:
6
+ * - pageInfo.nextPage (VTEX, Blog, most standard loaders)
7
+ * - nextPage (flat, some legacy loaders)
8
+ */
9
+ export function getNextPageUrl(data: unknown): string | undefined {
10
+ if (!data || typeof data !== "object") return undefined;
11
+ const d = data as Record<string, unknown>;
12
+ if (d.pageInfo && typeof d.pageInfo === "object") {
13
+ const pi = d.pageInfo as Record<string, unknown>;
14
+ if (typeof pi.nextPage === "string" && pi.nextPage) return pi.nextPage;
15
+ }
16
+ if (typeof d.nextPage === "string" && d.nextPage) return d.nextPage;
17
+ return undefined;
18
+ }
19
+
20
+ /**
21
+ * Accumulates pages of loader data for "Ver mais" / load-more pagination.
22
+ *
23
+ * Fresh's `usePartialSection({ mode: "append" })` appended HTML without a
24
+ * page reload. This hook is the TanStack equivalent: it calls the loader
25
+ * via POST /deco/invoke — passing the nextPage URL as `__pageUrl` so the
26
+ * loader reconstructs query, sort, and filter state from it — then pushes
27
+ * the result into local state.
28
+ *
29
+ * Usage in a migrated section:
30
+ * ```tsx
31
+ * "use client"
32
+ * const { pages, loadMore, loading, hasMore, error } = useLoadMore(
33
+ * props.page ?? { products: [], pageInfo: {} },
34
+ * "apps/vtex.ts/loaders/intelligentSearch/productListingPage.ts",
35
+ * pageUrl // resetKey: pass the current page URL so accumulated pages reset on filter/sort change
36
+ * )
37
+ * const allProducts = pages.flatMap(p => p.products ?? [])
38
+ * ```
39
+ *
40
+ * The component must be `"use client"` because this hook uses useState.
41
+ *
42
+ * @param resetKey - When this value changes, accumulated pages reset to [initial].
43
+ * Pass the current page URL (or any value that changes on filter/sort navigation)
44
+ * to clear the accumulated product list when the user changes filters.
45
+ * Without resetKey, add `key={pageUrl}` to the section element instead.
46
+ */
47
+ export function useLoadMore<T>(initial: T, loaderKey: string, resetKey?: unknown) {
48
+ const [pages, setPages] = useState<T[]>([initial]);
49
+ const [loading, setLoading] = useState(false);
50
+ const [error, setError] = useState<Error | null>(null);
51
+ // useRef guard prevents two rapid clicks both passing the !loading check
52
+ // before the async state update propagates (React state is not synchronous).
53
+ const inflight = useRef(false);
54
+ // Skip the reset effect on first mount — pages are already seeded from useState.
55
+ const isFirstRender = useRef(true);
56
+
57
+ useEffect(() => {
58
+ if (isFirstRender.current) {
59
+ isFirstRender.current = false;
60
+ return;
61
+ }
62
+ setPages([initial]);
63
+ setError(null);
64
+ // eslint-disable-next-line react-hooks/exhaustive-deps
65
+ }, [resetKey]);
66
+
67
+ const lastPage = pages[pages.length - 1];
68
+ const nextPageRel = getNextPageUrl(lastPage);
69
+ const hasMore = Boolean(nextPageRel);
70
+
71
+ const loadMore = useCallback(async () => {
72
+ if (!nextPageRel || inflight.current) return;
73
+ inflight.current = true;
74
+ setLoading(true);
75
+ setError(null);
76
+ try {
77
+ const abs = new URL(nextPageRel, location.href);
78
+ const res = await fetch("/deco/invoke", {
79
+ method: "POST",
80
+ headers: { "content-type": "application/json" },
81
+ body: JSON.stringify({
82
+ key: loaderKey,
83
+ props: { __pageUrl: abs.href, __pagePath: abs.pathname },
84
+ }),
85
+ });
86
+ if (!res.ok) throw new Error(`invoke ${res.status} for ${loaderKey}`);
87
+ const next = (await res.json()) as T;
88
+ setPages((prev) => [...prev, next]);
89
+ } catch (e) {
90
+ setError(e instanceof Error ? e : new Error(String(e)));
91
+ } finally {
92
+ inflight.current = false;
93
+ setLoading(false);
94
+ }
95
+ }, [nextPageRel, loaderKey]);
96
+
97
+ return { pages, loadMore, loading, hasMore, error };
98
+ }