@decocms/start 2.1.1 → 2.1.2

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/start",
3
- "version": "2.1.1",
3
+ "version": "2.1.2",
4
4
  "type": "module",
5
5
  "description": "Deco framework for TanStack Start - CMS bridge, admin protocol, hooks, schema generation",
6
6
  "main": "./src/index.ts",
@@ -0,0 +1,144 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ registerCacheableSections,
4
+ registerLayoutSections,
5
+ registerSectionLoader,
6
+ runSingleSectionLoader,
7
+ } from "./sectionLoaders";
8
+ import type { ResolvedSection } from "./resolve";
9
+
10
+ const G = globalThis as any;
11
+
12
+ beforeEach(() => {
13
+ G.__deco.sectionLoaderRegistry.clear();
14
+ G.__deco.layoutSections.clear();
15
+ G.__deco.cacheableSections.clear();
16
+ });
17
+
18
+ const makeSection = (
19
+ component: string,
20
+ props: Record<string, unknown> = {},
21
+ ): ResolvedSection => ({
22
+ component,
23
+ props,
24
+ key: component,
25
+ index: 0,
26
+ });
27
+
28
+ describe("runSingleSectionLoader — page context injection", () => {
29
+ it("injects __pageUrl and __pagePath into loader props", async () => {
30
+ const loader = vi.fn(async (props: Record<string, unknown>) => props);
31
+ registerSectionLoader("site/sections/SearchBanner.tsx", loader);
32
+
33
+ const section = makeSection("site/sections/SearchBanner.tsx", { foo: "bar" });
34
+ const request = new Request("https://store.com/lingerie?q=preto");
35
+
36
+ await runSingleSectionLoader(section, request);
37
+
38
+ expect(loader).toHaveBeenCalledTimes(1);
39
+ const [calledProps, calledReq] = loader.mock.calls[0];
40
+ expect(calledProps).toMatchObject({
41
+ foo: "bar",
42
+ __pageUrl: "https://store.com/lingerie?q=preto",
43
+ __pagePath: "/lingerie",
44
+ });
45
+ expect(calledReq).toBe(request);
46
+ });
47
+
48
+ it("preserves existing __pageUrl / __pagePath from props (site workaround compat)", async () => {
49
+ const loader = vi.fn(async (props: Record<string, unknown>) => props);
50
+ registerSectionLoader("site/sections/Custom.tsx", loader);
51
+
52
+ const section = makeSection("site/sections/Custom.tsx", {
53
+ __pageUrl: "https://override.example/page",
54
+ __pagePath: "/override",
55
+ });
56
+ const request = new Request("https://store.com/real-path");
57
+
58
+ await runSingleSectionLoader(section, request);
59
+
60
+ const [calledProps] = loader.mock.calls[0];
61
+ expect(calledProps.__pageUrl).toBe("https://override.example/page");
62
+ expect(calledProps.__pagePath).toBe("/override");
63
+ });
64
+
65
+ it("does not throw when request.url is invalid", async () => {
66
+ const loader = vi.fn(async (props: Record<string, unknown>) => props);
67
+ registerSectionLoader("site/sections/X.tsx", loader);
68
+
69
+ const section = makeSection("site/sections/X.tsx", { foo: 1 });
70
+ const badReq = { url: "not a url" } as unknown as Request;
71
+
72
+ await expect(runSingleSectionLoader(section, badReq)).resolves.toBeDefined();
73
+ expect(loader).toHaveBeenCalled();
74
+ });
75
+
76
+ it("returns section unchanged when no loader is registered", async () => {
77
+ const section = makeSection("site/sections/NoLoader.tsx", { foo: 1 });
78
+ const result = await runSingleSectionLoader(
79
+ section,
80
+ new Request("https://store.com/"),
81
+ );
82
+ expect(result).toBe(section);
83
+ });
84
+ });
85
+
86
+ describe("runSingleSectionLoader — cache keying", () => {
87
+ it("cacheable sections share a cache entry across different page URLs", async () => {
88
+ const loader = vi.fn(async (props: Record<string, unknown>) => ({
89
+ ...props,
90
+ enriched: true,
91
+ }));
92
+ registerSectionLoader("site/sections/Shelf.tsx", loader);
93
+ registerCacheableSections({ "site/sections/Shelf.tsx": { maxAge: 60_000 } });
94
+
95
+ const section = makeSection("site/sections/Shelf.tsx", { title: "Best" });
96
+
97
+ await runSingleSectionLoader(section, new Request("https://store.com/page-a"));
98
+ await runSingleSectionLoader(section, new Request("https://store.com/page-b"));
99
+ await runSingleSectionLoader(section, new Request("https://store.com/page-c"));
100
+
101
+ // Without URL-agnostic cache keys, this would be 3.
102
+ expect(loader).toHaveBeenCalledTimes(1);
103
+ });
104
+
105
+ it("layout sections cache by component name and reuse across pages", async () => {
106
+ const loader = vi.fn(async (props: Record<string, unknown>) => props);
107
+ registerSectionLoader("site/sections/Header.tsx", loader);
108
+ registerLayoutSections(["site/sections/Header.tsx"]);
109
+
110
+ const section = makeSection("site/sections/Header.tsx", { variant: "default" });
111
+
112
+ await runSingleSectionLoader(section, new Request("https://store.com/a"));
113
+ await runSingleSectionLoader(section, new Request("https://store.com/b"));
114
+
115
+ expect(loader).toHaveBeenCalledTimes(1);
116
+ });
117
+
118
+ it("regular (non-cached) sections re-run on every request", async () => {
119
+ const loader = vi.fn(async (props: Record<string, unknown>) => props);
120
+ registerSectionLoader("site/sections/Reg.tsx", loader);
121
+
122
+ const section = makeSection("site/sections/Reg.tsx", {});
123
+ await runSingleSectionLoader(section, new Request("https://store.com/a"));
124
+ await runSingleSectionLoader(section, new Request("https://store.com/b"));
125
+
126
+ expect(loader).toHaveBeenCalledTimes(2);
127
+ });
128
+ });
129
+
130
+ describe("runSingleSectionLoader — error handling", () => {
131
+ it("falls back to original section when loader throws", async () => {
132
+ const loader = vi.fn(async () => {
133
+ throw new Error("boom");
134
+ });
135
+ registerSectionLoader("site/sections/Boom.tsx", loader);
136
+
137
+ const section = makeSection("site/sections/Boom.tsx", { x: 1 });
138
+ const result = await runSingleSectionLoader(
139
+ section,
140
+ new Request("https://store.com/"),
141
+ );
142
+ expect(result).toEqual(section);
143
+ });
144
+ });
@@ -269,6 +269,47 @@ export async function runSectionLoaders(
269
269
  return Promise.all(sections.map((section) => runSingleSectionLoader(section, request)));
270
270
  }
271
271
 
272
+ /**
273
+ * Inject the active request's URL and path into section props so site
274
+ * loaders can read `props.__pageUrl` / `props.__pagePath` without having
275
+ * to derive them from `req.url` themselves.
276
+ *
277
+ * The framework already injects these for commerce loaders (resolve.ts),
278
+ * but section loaders (e.g. category SearchBanner, breadcrumb-aware FAQs)
279
+ * also need to know the active page. Without this, callers had to wrap
280
+ * `loader(...)` themselves in a custom `delegateAfter`-style helper —
281
+ * forgetting it produced silent rendering bugs (empty banners, default
282
+ * fallbacks).
283
+ *
284
+ * Existing values in `props` win — sites that already pre-populated
285
+ * `__pageUrl` (e.g. via a custom mixin) keep their value untouched.
286
+ *
287
+ * Note: this runs only at the point we hand props to the user's loader.
288
+ * The cacheable-section cache key hashes the *original* props (URL-agnostic),
289
+ * so sections registered via `registerCacheableSections` keep sharing a
290
+ * single cache entry across pages.
291
+ */
292
+ function injectPageContext(
293
+ props: Record<string, unknown>,
294
+ request: Request,
295
+ ): Record<string, unknown> {
296
+ let url: URL;
297
+ try {
298
+ url = new URL(request.url);
299
+ } catch {
300
+ return props;
301
+ }
302
+ const enriched = { ...props } as Record<string, unknown>;
303
+ if (enriched.__pageUrl === undefined) enriched.__pageUrl = request.url;
304
+ if (enriched.__pagePath === undefined) enriched.__pagePath = url.pathname;
305
+ return enriched;
306
+ }
307
+
308
+ /** Wrap a loader so it receives __pageUrl/__pagePath in its props. */
309
+ function withPageContext(loader: SectionLoaderFn): SectionLoaderFn {
310
+ return (props, req) => loader(injectPageContext(props, req), req);
311
+ }
312
+
272
313
  /**
273
314
  * Run a single section's registered loader.
274
315
  * Used by both `runSectionLoaders` (batch) and `loadDeferredSection` (individual).
@@ -285,9 +326,15 @@ export async function runSingleSectionLoader(
285
326
  const loader = loaderRegistry.get(section.component);
286
327
  if (!loader) return section;
287
328
 
329
+ // Wrap the loader so __pageUrl/__pagePath are injected at the call site.
330
+ // Cache keys (component name for layout, component+propsHash for cacheable)
331
+ // are computed from the *original* section.props — keeping cache entries
332
+ // URL-agnostic and shared across pages.
333
+ const wrapped = withPageContext(loader);
334
+
288
335
  if (layoutSections.has(section.component)) {
289
336
  try {
290
- return await resolveLayoutSection(section, loader, request);
337
+ return await resolveLayoutSection(section, wrapped, request);
291
338
  } catch (error) {
292
339
  console.error(`[SectionLoader] Error in layout "${section.component}":`, error);
293
340
  return section;
@@ -297,7 +344,7 @@ export async function runSingleSectionLoader(
297
344
  const cacheConfig = cacheableSections.get(section.component);
298
345
  if (cacheConfig) {
299
346
  try {
300
- return await runCacheableSectionLoader(section, loader, request, cacheConfig);
347
+ return await runCacheableSectionLoader(section, wrapped, request, cacheConfig);
301
348
  } catch (error) {
302
349
  console.error(`[SectionLoader] Error in cacheable "${section.component}":`, error);
303
350
  return section;
@@ -305,7 +352,10 @@ export async function runSingleSectionLoader(
305
352
  }
306
353
 
307
354
  try {
308
- const enrichedProps = await loader(section.props as Record<string, unknown>, request);
355
+ const enrichedProps = await wrapped(
356
+ section.props as Record<string, unknown>,
357
+ request,
358
+ );
309
359
  return { ...section, props: enrichedProps };
310
360
  } catch (error) {
311
361
  console.error(`[SectionLoader] Error in "${section.component}":`, error);