@decocms/start 2.1.0 → 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 +1 -1
- package/src/cms/sectionLoaders.test.ts +144 -0
- package/src/cms/sectionLoaders.ts +53 -3
- package/src/vite/plugin.js +16 -6
package/package.json
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
|
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);
|
package/src/vite/plugin.js
CHANGED
|
@@ -122,19 +122,29 @@ export function decoVitePlugin() {
|
|
|
122
122
|
if (siteName) {
|
|
123
123
|
const envName = process.env.DECO_ENV_NAME || "dev";
|
|
124
124
|
|
|
125
|
+
// Daemon files are .ts and live inside node_modules. Node's
|
|
126
|
+
// experimental strip-types refuses to transpile node_modules, so
|
|
127
|
+
// a plain dynamic `import()` blows up under `vite dev`. Use tsx's
|
|
128
|
+
// ad-hoc loader (`tsImport`) — scoped to this import, doesn't
|
|
129
|
+
// register a global hook.
|
|
130
|
+
const loadDaemon = (specifier) =>
|
|
131
|
+
import("tsx/esm/api").then(({ tsImport }) => tsImport(specifier, import.meta.url));
|
|
132
|
+
|
|
125
133
|
// Add daemon middleware (x-daemon-api interception + auth + volumes + SSE + admin routes)
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
134
|
+
loadDaemon("../daemon/middleware.ts")
|
|
135
|
+
.then(({ createDaemonMiddleware }) => {
|
|
136
|
+
server.middlewares.use(createDaemonMiddleware({ site: siteName, server }));
|
|
137
|
+
})
|
|
138
|
+
.catch((err) => {
|
|
139
|
+
console.warn("[deco] Failed to load daemon middleware:", err.message);
|
|
140
|
+
});
|
|
131
141
|
|
|
132
142
|
// Start tunnel after HTTP server is listening (so we know the real port)
|
|
133
143
|
server.httpServer?.once("listening", async () => {
|
|
134
144
|
const addr = server.httpServer?.address();
|
|
135
145
|
const port = typeof addr === "object" && addr ? addr.port : 5173;
|
|
136
146
|
try {
|
|
137
|
-
const { startTunnel } = await
|
|
147
|
+
const { startTunnel } = await loadDaemon("../daemon/tunnel.ts");
|
|
138
148
|
const tunnel = await startTunnel({
|
|
139
149
|
site: siteName,
|
|
140
150
|
env: envName,
|