@decocms/blocks 7.0.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 +97 -0
- package/src/cms/applySectionConventions.ts +128 -0
- package/src/cms/blockSource.test.ts +67 -0
- package/src/cms/blockSource.ts +124 -0
- package/src/cms/index.ts +104 -0
- package/src/cms/layoutCacheRace.test.ts +146 -0
- package/src/cms/loadDecofileDirectory.test.ts +52 -0
- package/src/cms/loadDecofileDirectory.ts +48 -0
- package/src/cms/loader.test.ts +184 -0
- package/src/cms/loader.ts +284 -0
- package/src/cms/registry.test.ts +118 -0
- package/src/cms/registry.ts +252 -0
- package/src/cms/resolve.test.ts +547 -0
- package/src/cms/resolve.ts +2003 -0
- package/src/cms/schema.ts +993 -0
- package/src/cms/sectionLoaders.test.ts +409 -0
- package/src/cms/sectionLoaders.ts +562 -0
- package/src/cms/sectionMixins.test.ts +163 -0
- package/src/cms/sectionMixins.ts +153 -0
- package/src/hooks/LazySection.tsx +121 -0
- package/src/hooks/LiveControls.tsx +122 -0
- package/src/hooks/RenderSection.test.tsx +81 -0
- package/src/hooks/RenderSection.tsx +91 -0
- package/src/hooks/SectionErrorFallback.tsx +97 -0
- package/src/hooks/index.ts +4 -0
- package/src/index.ts +8 -0
- package/src/matchers/builtins.test.ts +251 -0
- package/src/matchers/builtins.ts +437 -0
- package/src/matchers/countryNames.ts +104 -0
- package/src/matchers/override.test.ts +205 -0
- package/src/matchers/override.ts +136 -0
- package/src/matchers/posthog.ts +154 -0
- package/src/middleware/decoState.ts +55 -0
- package/src/middleware/healthMetrics.ts +133 -0
- package/src/middleware/hydrationContext.test.ts +61 -0
- package/src/middleware/hydrationContext.ts +79 -0
- package/src/middleware/index.ts +88 -0
- package/src/middleware/liveness.ts +21 -0
- package/src/middleware/observability.test.ts +238 -0
- package/src/middleware/observability.ts +620 -0
- package/src/middleware/validateSection.test.ts +147 -0
- package/src/middleware/validateSection.ts +100 -0
- package/src/sdk/abTesting.test.ts +326 -0
- package/src/sdk/abTesting.ts +499 -0
- package/src/sdk/analytics.ts +77 -0
- package/src/sdk/cacheHeaders.test.ts +115 -0
- package/src/sdk/cacheHeaders.ts +424 -0
- package/src/sdk/cachedLoader.ts +364 -0
- package/src/sdk/clx.ts +5 -0
- package/src/sdk/cn.test.ts +34 -0
- package/src/sdk/cn.ts +28 -0
- package/src/sdk/composite.test.ts +121 -0
- package/src/sdk/composite.ts +114 -0
- package/src/sdk/cookie.test.ts +108 -0
- package/src/sdk/cookie.ts +129 -0
- package/src/sdk/crypto.ts +185 -0
- package/src/sdk/csp.ts +59 -0
- package/src/sdk/djb2.ts +20 -0
- package/src/sdk/encoding.test.ts +71 -0
- package/src/sdk/encoding.ts +47 -0
- package/src/sdk/env.ts +31 -0
- package/src/sdk/http.test.ts +71 -0
- package/src/sdk/http.ts +124 -0
- package/src/sdk/index.ts +90 -0
- package/src/sdk/inflightTimeout.test.ts +35 -0
- package/src/sdk/inflightTimeout.ts +53 -0
- package/src/sdk/instrumentedFetch.test.ts +559 -0
- package/src/sdk/instrumentedFetch.ts +339 -0
- package/src/sdk/invoke.test.ts +115 -0
- package/src/sdk/invoke.ts +260 -0
- package/src/sdk/logger.test.ts +432 -0
- package/src/sdk/logger.ts +304 -0
- package/src/sdk/mergeCacheControl.ts +150 -0
- package/src/sdk/normalizeUrls.ts +91 -0
- package/src/sdk/observability.ts +109 -0
- package/src/sdk/otel.test.ts +526 -0
- package/src/sdk/otel.ts +981 -0
- package/src/sdk/otelAdapters/clickhouseCollector.ts +65 -0
- package/src/sdk/otelAdapters.test.ts +89 -0
- package/src/sdk/otelAdapters.ts +144 -0
- package/src/sdk/otelHttpLog.test.ts +457 -0
- package/src/sdk/otelHttpLog.ts +419 -0
- package/src/sdk/otelHttpMeter.test.ts +292 -0
- package/src/sdk/otelHttpMeter.ts +506 -0
- package/src/sdk/otelHttpTracer.test.ts +474 -0
- package/src/sdk/otelHttpTracer.ts +543 -0
- package/src/sdk/redirects.ts +225 -0
- package/src/sdk/requestContext.ts +281 -0
- package/src/sdk/requestContextStorage.browser.test.ts +29 -0
- package/src/sdk/requestContextStorage.browser.ts +43 -0
- package/src/sdk/requestContextStorage.ts +35 -0
- package/src/sdk/retry.ts +45 -0
- package/src/sdk/serverTimings.ts +68 -0
- package/src/sdk/signal.ts +42 -0
- package/src/sdk/sitemap.ts +160 -0
- package/src/sdk/urlRedaction.test.ts +73 -0
- package/src/sdk/urlRedaction.ts +82 -0
- package/src/sdk/urlUtils.ts +134 -0
- package/src/sdk/useDevice.test.ts +130 -0
- package/src/sdk/useDevice.ts +109 -0
- package/src/sdk/useDeviceContext.tsx +108 -0
- package/src/sdk/useId.ts +7 -0
- package/src/sdk/useScript.test.ts +128 -0
- package/src/sdk/useScript.ts +210 -0
- package/src/sdk/useSuggestions.test.ts +230 -0
- package/src/sdk/useSuggestions.ts +188 -0
- package/src/sdk/wrapCaughtErrors.ts +107 -0
- package/src/setup.ts +119 -0
- package/src/types/index.ts +39 -0
- package/src/types/widgets.ts +14 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Section mixins — focused on `withSectionLoader` since the device/mobile
|
|
3
|
+
* mixins are exercised by sectionLoaders integration tests already.
|
|
4
|
+
*/
|
|
5
|
+
import { describe, expect, it, vi } from "vitest";
|
|
6
|
+
import {
|
|
7
|
+
compose,
|
|
8
|
+
withDevice,
|
|
9
|
+
withMobile,
|
|
10
|
+
withSearchParam,
|
|
11
|
+
withSectionLoader,
|
|
12
|
+
} from "./sectionMixins";
|
|
13
|
+
|
|
14
|
+
// `__requestDependent` is the runtime tag the framework reads in
|
|
15
|
+
// `registerSectionLoaders` to decide whether to emit the layout-cache
|
|
16
|
+
// contamination warning (#206). Any future mixin that depends on the
|
|
17
|
+
// request must opt in by setting this flag — and `compose` must propagate
|
|
18
|
+
// it whenever any input has it.
|
|
19
|
+
const isRequestDependent = (fn: unknown): boolean =>
|
|
20
|
+
typeof fn === "function" &&
|
|
21
|
+
(fn as { __requestDependent?: boolean }).__requestDependent === true;
|
|
22
|
+
|
|
23
|
+
const makeReq = (url = "https://store.example/foo?q=hello") =>
|
|
24
|
+
new Request(url, { headers: { "user-agent": "vitest" } });
|
|
25
|
+
|
|
26
|
+
describe("withSectionLoader", () => {
|
|
27
|
+
it("invokes the section's exported loader and returns its result", async () => {
|
|
28
|
+
const loader = vi.fn(async (props: Record<string, unknown>) => ({
|
|
29
|
+
...props,
|
|
30
|
+
url: "https://store.example/foo?q=hello",
|
|
31
|
+
enriched: true,
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
const mixin = withSectionLoader(async () => ({ loader }));
|
|
35
|
+
const result = await mixin({ original: "value" }, makeReq());
|
|
36
|
+
|
|
37
|
+
expect(loader).toHaveBeenCalledTimes(1);
|
|
38
|
+
expect(result).toEqual({
|
|
39
|
+
original: "value",
|
|
40
|
+
url: "https://store.example/foo?q=hello",
|
|
41
|
+
enriched: true,
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("returns props unchanged when module has no loader export", async () => {
|
|
46
|
+
const mixin = withSectionLoader(async () => ({ default: () => null }));
|
|
47
|
+
const props = { foo: "bar" };
|
|
48
|
+
const result = await mixin(props, makeReq());
|
|
49
|
+
expect(result).toBe(props);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("returns props unchanged when module is undefined / loader is not a function", async () => {
|
|
53
|
+
const mixin = withSectionLoader(async () => undefined);
|
|
54
|
+
const props = { foo: "bar" };
|
|
55
|
+
expect(await mixin(props, makeReq())).toBe(props);
|
|
56
|
+
|
|
57
|
+
const mixinWithBadLoader = withSectionLoader(async () => ({
|
|
58
|
+
loader: "not-a-function",
|
|
59
|
+
}));
|
|
60
|
+
expect(await mixinWithBadLoader(props, makeReq())).toBe(props);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("swallows loader errors and returns the original props", async () => {
|
|
64
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
65
|
+
const mixin = withSectionLoader(async () => ({
|
|
66
|
+
loader: async () => {
|
|
67
|
+
throw new Error("boom");
|
|
68
|
+
},
|
|
69
|
+
}));
|
|
70
|
+
const props = { foo: "bar" };
|
|
71
|
+
const result = await mixin(props, makeReq());
|
|
72
|
+
expect(result).toBe(props);
|
|
73
|
+
expect(errorSpy).toHaveBeenCalledWith(
|
|
74
|
+
"[withSectionLoader] section loader threw:",
|
|
75
|
+
expect.any(Error),
|
|
76
|
+
);
|
|
77
|
+
errorSpy.mockRestore();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("falls back to original props when loader returns undefined", async () => {
|
|
81
|
+
const mixin = withSectionLoader(async () => ({
|
|
82
|
+
loader: async () => undefined,
|
|
83
|
+
}));
|
|
84
|
+
const props = { foo: "bar" };
|
|
85
|
+
expect(await mixin(props, makeReq())).toBe(props);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("composes alongside mixins: mixins run first, then the section loader sees the enriched props", async () => {
|
|
89
|
+
const seen: Array<Record<string, unknown>> = [];
|
|
90
|
+
const sectionLoader = vi.fn(
|
|
91
|
+
async (props: Record<string, unknown>) => {
|
|
92
|
+
seen.push({ ...props });
|
|
93
|
+
return { ...props, sectionLoaderRan: true };
|
|
94
|
+
},
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const composed = compose(
|
|
98
|
+
withSearchParam(),
|
|
99
|
+
withSectionLoader(async () => ({ loader: sectionLoader })),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const result = await composed({ original: 1 }, makeReq());
|
|
103
|
+
|
|
104
|
+
expect(seen).toHaveLength(1);
|
|
105
|
+
// The section loader sees the enriched props (currentSearchParam injected by withSearchParam)
|
|
106
|
+
expect(seen[0]).toMatchObject({
|
|
107
|
+
original: 1,
|
|
108
|
+
currentSearchParam: "hello",
|
|
109
|
+
});
|
|
110
|
+
expect(result).toMatchObject({
|
|
111
|
+
original: 1,
|
|
112
|
+
currentSearchParam: "hello",
|
|
113
|
+
sectionLoaderRan: true,
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("loads the module lazily — modImport is only called on first invocation", async () => {
|
|
118
|
+
const modImport = vi.fn(async () => ({
|
|
119
|
+
loader: async (p: Record<string, unknown>) => ({ ...p, ran: true }),
|
|
120
|
+
}));
|
|
121
|
+
const mixin = withSectionLoader(modImport);
|
|
122
|
+
expect(modImport).not.toHaveBeenCalled();
|
|
123
|
+
|
|
124
|
+
await mixin({ a: 1 }, makeReq());
|
|
125
|
+
expect(modImport).toHaveBeenCalledTimes(1);
|
|
126
|
+
|
|
127
|
+
// A second invocation calls the import again — caching is the
|
|
128
|
+
// dynamic import's responsibility (which the runtime memoises).
|
|
129
|
+
await mixin({ a: 2 }, makeReq());
|
|
130
|
+
expect(modImport).toHaveBeenCalledTimes(2);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("request-dependent tagging (#206)", () => {
|
|
135
|
+
it("tags withDevice", () => {
|
|
136
|
+
expect(isRequestDependent(withDevice())).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("tags withMobile", () => {
|
|
140
|
+
expect(isRequestDependent(withMobile())).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("tags withSearchParam", () => {
|
|
144
|
+
expect(isRequestDependent(withSearchParam())).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("does NOT tag withSectionLoader (its loader may or may not touch req)", () => {
|
|
148
|
+
expect(isRequestDependent(withSectionLoader(async () => ({})))).toBe(false);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("compose propagates the flag when any input is request-dependent", () => {
|
|
152
|
+
expect(isRequestDependent(compose(withDevice(), async (p) => p))).toBe(true);
|
|
153
|
+
expect(isRequestDependent(compose(async (p) => p, withSearchParam()))).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("compose does NOT set the flag when no input is request-dependent", () => {
|
|
157
|
+
expect(isRequestDependent(compose(async (p) => p, async (p) => p))).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("empty compose() is not request-dependent", () => {
|
|
161
|
+
expect(isRequestDependent(compose())).toBe(false);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Section Loader Mixins
|
|
3
|
+
*
|
|
4
|
+
* Reusable section loader factories for common patterns like device detection,
|
|
5
|
+
* mobile flag injection, and search param extraction. Eliminates repetitive
|
|
6
|
+
* `(props, req) => ({ ...props, device: detectDevice(...) })` boilerplate.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* import { withDevice, withMobile, compose } from "@decocms/start/cms";
|
|
11
|
+
*
|
|
12
|
+
* registerSectionLoaders({
|
|
13
|
+
* "site/sections/Product/ProductShelf.tsx": withDevice(),
|
|
14
|
+
* "site/sections/Images/Carousel.tsx": withMobile(),
|
|
15
|
+
* "site/sections/Header/Header.tsx": compose(withDevice(), withSearchParam()),
|
|
16
|
+
* });
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
import { detectDevice } from "../sdk/useDevice";
|
|
20
|
+
import type { SectionLoaderFn } from "./sectionLoaders";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Mixins that derive props from the incoming request (UA, search params,
|
|
24
|
+
* cookies, geo, segment) carry this flag so the framework can warn when a
|
|
25
|
+
* site accidentally combines them with a layout-cached section — the layout
|
|
26
|
+
* cache is keyed only by component path, so the first visitor's variant
|
|
27
|
+
* would be served to all viewers for the cache TTL. See #206.
|
|
28
|
+
*/
|
|
29
|
+
type TaggedSectionLoaderFn = SectionLoaderFn & { __requestDependent?: boolean };
|
|
30
|
+
|
|
31
|
+
function tagRequestDependent(fn: SectionLoaderFn): SectionLoaderFn {
|
|
32
|
+
(fn as TaggedSectionLoaderFn).__requestDependent = true;
|
|
33
|
+
return fn;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Injects `device: "mobile" | "desktop" | "tablet"` from the request User-Agent.
|
|
38
|
+
*/
|
|
39
|
+
export function withDevice(): SectionLoaderFn {
|
|
40
|
+
return tagRequestDependent((props, req) => ({
|
|
41
|
+
...props,
|
|
42
|
+
device: detectDevice(req.headers.get("user-agent") ?? ""),
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Injects `isMobile: boolean` (true for mobile and tablet) from the request User-Agent.
|
|
48
|
+
*/
|
|
49
|
+
export function withMobile(): SectionLoaderFn {
|
|
50
|
+
return tagRequestDependent((props, req) => {
|
|
51
|
+
const d = detectDevice(req.headers.get("user-agent") ?? "");
|
|
52
|
+
return { ...props, isMobile: d === "mobile" || d === "tablet" };
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const REGEX_QUERY_VALUE = /[?&]q=([^&]*)/;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Injects `currentSearchParam: string | undefined` extracted from the `?q=` URL parameter.
|
|
60
|
+
*/
|
|
61
|
+
export function withSearchParam(): SectionLoaderFn {
|
|
62
|
+
return tagRequestDependent((props, req) => {
|
|
63
|
+
const match = req.url.match(REGEX_QUERY_VALUE);
|
|
64
|
+
return {
|
|
65
|
+
...props,
|
|
66
|
+
currentSearchParam: match ? decodeURIComponent(match[1]) : undefined,
|
|
67
|
+
};
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Composes multiple section loader mixins into a single loader.
|
|
73
|
+
* Each mixin's result is merged left-to-right (later mixins override earlier ones).
|
|
74
|
+
*
|
|
75
|
+
* Propagates the `__requestDependent` flag from any input mixin so the
|
|
76
|
+
* layout-cache contamination warning (#206) fires even when a request-
|
|
77
|
+
* dependent mixin is hidden inside a compose() chain.
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* ```ts
|
|
81
|
+
* compose(withDevice(), withSearchParam())
|
|
82
|
+
* // Equivalent to: (props, req) => ({ ...props, device: ..., currentSearchParam: ... })
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
export function compose(...mixins: SectionLoaderFn[]): SectionLoaderFn {
|
|
86
|
+
const composed: SectionLoaderFn = async (props, req) => {
|
|
87
|
+
let result = { ...props };
|
|
88
|
+
for (const mixin of mixins) {
|
|
89
|
+
const partial = await mixin(result, req);
|
|
90
|
+
result = { ...result, ...partial };
|
|
91
|
+
}
|
|
92
|
+
return result;
|
|
93
|
+
};
|
|
94
|
+
if (mixins.some((m) => (m as TaggedSectionLoaderFn).__requestDependent)) {
|
|
95
|
+
(composed as TaggedSectionLoaderFn).__requestDependent = true;
|
|
96
|
+
}
|
|
97
|
+
return composed;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Wraps a section module's exported `loader` so it can be composed alongside
|
|
102
|
+
* mixins like {@link withDevice}, {@link withMobile}, {@link withSearchParam}.
|
|
103
|
+
*
|
|
104
|
+
* The `modImport` argument is a lazy factory (typically `() => import("~/sections/...")`)
|
|
105
|
+
* — the module is loaded on first call. If the module does not export a
|
|
106
|
+
* `loader`, the original `props` are returned unchanged (no-op).
|
|
107
|
+
*
|
|
108
|
+
* Why this exists: the migrator and many sites lifted from Fresh declare a
|
|
109
|
+
* `loader` export on the section file (often re-exported from the inner
|
|
110
|
+
* component). That loader sets things like `url: req.url`, runs platform
|
|
111
|
+
* invocations, or calls the section's domain logic. If the section is wired
|
|
112
|
+
* in `registerSectionLoaders` with mixin-only (e.g. `withSearchParam()`),
|
|
113
|
+
* the section's own loader is silently *replaced* — its work never runs and
|
|
114
|
+
* its returned props are dropped.
|
|
115
|
+
*
|
|
116
|
+
* Compose this helper FIRST in the chain so mixin-injected props
|
|
117
|
+
* (`device`, `currentSearchParam`, …) are available to the section's loader,
|
|
118
|
+
* then the section's loader has the final word over what is returned.
|
|
119
|
+
*
|
|
120
|
+
* @example
|
|
121
|
+
* ```ts
|
|
122
|
+
* import {
|
|
123
|
+
* compose,
|
|
124
|
+
* withMobile,
|
|
125
|
+
* withSearchParam,
|
|
126
|
+
* withSectionLoader,
|
|
127
|
+
* } from "@decocms/start/cms";
|
|
128
|
+
*
|
|
129
|
+
* registerSectionLoaders({
|
|
130
|
+
* "site/sections/Product/SearchContainerV2.tsx": compose(
|
|
131
|
+
* withMobile(),
|
|
132
|
+
* withSearchParam(),
|
|
133
|
+
* withSectionLoader(() => import("~/sections/Product/SearchContainerV2")),
|
|
134
|
+
* ),
|
|
135
|
+
* });
|
|
136
|
+
* ```
|
|
137
|
+
*/
|
|
138
|
+
export function withSectionLoader(
|
|
139
|
+
modImport: () => Promise<unknown>,
|
|
140
|
+
): SectionLoaderFn {
|
|
141
|
+
return async (props, req) => {
|
|
142
|
+
const mod = (await modImport()) as { loader?: unknown } | undefined;
|
|
143
|
+
const loader = mod?.loader;
|
|
144
|
+
if (typeof loader !== "function") return props;
|
|
145
|
+
try {
|
|
146
|
+
const result = await (loader as SectionLoaderFn)(props, req);
|
|
147
|
+
return result ?? props;
|
|
148
|
+
} catch (error) {
|
|
149
|
+
console.error("[withSectionLoader] section loader threw:", error);
|
|
150
|
+
return props;
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* LazySection -- Intersection Observer-based deferred rendering.
|
|
5
|
+
*
|
|
6
|
+
* Wraps section content and defers rendering until the element scrolls
|
|
7
|
+
* into view. Uses IntersectionObserver for zero-JS-cost detection and
|
|
8
|
+
* renders a lightweight placeholder until the content is needed.
|
|
9
|
+
*
|
|
10
|
+
* For SSR: the placeholder is rendered server-side. On hydration,
|
|
11
|
+
* the observer is set up and content loads when visible.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```tsx
|
|
15
|
+
* <LazySection fallback={<div style={{ height: 400 }} />}>
|
|
16
|
+
* <HeavyProductCarousel products={products} />
|
|
17
|
+
* </LazySection>
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* `"use client"` added by Phase 2 Task 9 (@decocms/nextjs's next-smoke
|
|
21
|
+
* fixture): this file uses `useEffect`/`useRef`/`useState`, which Next's App
|
|
22
|
+
* Router statically rejects in any file reachable from a Server Component's
|
|
23
|
+
* module graph unless the file itself declares the client boundary. Neither
|
|
24
|
+
* `@decocms/tanstack` (Vite, no RSC directive enforcement) nor this
|
|
25
|
+
* package's Vitest/jsdom unit tests caught this — `@decocms/nextjs` is the
|
|
26
|
+
* first consumer to go through Next's real RSC-aware webpack build.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { type ReactNode, useEffect, useRef, useState } from "react";
|
|
30
|
+
|
|
31
|
+
export interface LazySectionProps {
|
|
32
|
+
children: ReactNode;
|
|
33
|
+
/**
|
|
34
|
+
* Content shown before the section scrolls into view.
|
|
35
|
+
* Should have a fixed height to prevent layout shifts.
|
|
36
|
+
*/
|
|
37
|
+
fallback?: ReactNode;
|
|
38
|
+
/**
|
|
39
|
+
* IntersectionObserver rootMargin.
|
|
40
|
+
* Positive values trigger loading before the element is visible.
|
|
41
|
+
* @default "200px"
|
|
42
|
+
*/
|
|
43
|
+
rootMargin?: string;
|
|
44
|
+
/**
|
|
45
|
+
* Minimum height for the wrapper div (prevents CLS).
|
|
46
|
+
* Applied as CSS min-height.
|
|
47
|
+
*/
|
|
48
|
+
minHeight?: string | number;
|
|
49
|
+
/**
|
|
50
|
+
* CSS class for the wrapper div.
|
|
51
|
+
*/
|
|
52
|
+
className?: string;
|
|
53
|
+
/**
|
|
54
|
+
* If true, render the content immediately (bypass lazy loading).
|
|
55
|
+
* Useful for sections above the fold.
|
|
56
|
+
* @default false
|
|
57
|
+
*/
|
|
58
|
+
eager?: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function LazySection({
|
|
62
|
+
children,
|
|
63
|
+
fallback,
|
|
64
|
+
rootMargin = "200px",
|
|
65
|
+
minHeight,
|
|
66
|
+
className,
|
|
67
|
+
eager = false,
|
|
68
|
+
}: LazySectionProps) {
|
|
69
|
+
const [isVisible, setVisible] = useState(eager);
|
|
70
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (eager || isVisible) return;
|
|
74
|
+
|
|
75
|
+
const element = ref.current;
|
|
76
|
+
if (!element) return;
|
|
77
|
+
|
|
78
|
+
if (typeof IntersectionObserver === "undefined") {
|
|
79
|
+
setVisible(true);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const observer = new IntersectionObserver(
|
|
84
|
+
([entry]) => {
|
|
85
|
+
if (entry?.isIntersecting) {
|
|
86
|
+
setVisible(true);
|
|
87
|
+
observer.disconnect();
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
{ rootMargin },
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
observer.observe(element);
|
|
94
|
+
return () => observer.disconnect();
|
|
95
|
+
}, [eager, isVisible, rootMargin]);
|
|
96
|
+
|
|
97
|
+
const style: React.CSSProperties | undefined = minHeight
|
|
98
|
+
? { minHeight: typeof minHeight === "number" ? `${minHeight}px` : minHeight }
|
|
99
|
+
: undefined;
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<div ref={ref} className={className} style={style}>
|
|
103
|
+
{isVisible ? children : (fallback ?? null)}
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Determine if a section index is "below the fold" and should be lazy-loaded.
|
|
110
|
+
*
|
|
111
|
+
* Used by DecoPageRenderer to auto-wrap distant sections.
|
|
112
|
+
*
|
|
113
|
+
* @param index - Zero-based section index on the page
|
|
114
|
+
* @param foldThreshold - Sections at or above this index are below the fold.
|
|
115
|
+
* Defaults to `Infinity` (nothing below the fold), matching the framework
|
|
116
|
+
* default where the admin ⚡ toggle — not position — drives deferral.
|
|
117
|
+
* @default foldThreshold = Infinity
|
|
118
|
+
*/
|
|
119
|
+
export function isBelowFold(index: number, foldThreshold = Infinity): boolean {
|
|
120
|
+
return index >= foldThreshold;
|
|
121
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
interface LiveControlsProps {
|
|
4
|
+
site?: string;
|
|
5
|
+
page?: { id?: string; pathTemplate?: string };
|
|
6
|
+
flags?: any[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* LiveControls bridges the deco admin (parent window) with the storefront (iframe).
|
|
11
|
+
*
|
|
12
|
+
* Mirrors production behavior (apps/website/components/_Controls.tsx):
|
|
13
|
+
* 1. Injects __DECO_STATE for the admin to read
|
|
14
|
+
* 2. Listens for postMessage events from the admin (inject scripts, scroll, rerender)
|
|
15
|
+
* 3. "." opens admin in same tab, Ctrl/Cmd+"." opens in new tab, Ctrl+Shift+E also works
|
|
16
|
+
*/
|
|
17
|
+
export function LiveControls({ site, page, flags }: LiveControlsProps) {
|
|
18
|
+
return (
|
|
19
|
+
<>
|
|
20
|
+
<script
|
|
21
|
+
id="__DECO_STATE"
|
|
22
|
+
type="application/json"
|
|
23
|
+
dangerouslySetInnerHTML={{
|
|
24
|
+
__html: JSON.stringify({
|
|
25
|
+
page: page || {},
|
|
26
|
+
site: { name: site || "storefront" },
|
|
27
|
+
flags: flags || [],
|
|
28
|
+
}),
|
|
29
|
+
}}
|
|
30
|
+
/>
|
|
31
|
+
<LiveControlsScript />
|
|
32
|
+
</>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function LiveControlsScript() {
|
|
37
|
+
const script = `
|
|
38
|
+
(function() {
|
|
39
|
+
if (window.__DECO_LIVE_CONTROLS__) return;
|
|
40
|
+
window.__DECO_LIVE_CONTROLS__ = true;
|
|
41
|
+
|
|
42
|
+
var TRUSTED_ORIGINS = ["https://deco.cx", "https://admin.deco.cx", "https://play.deco.cx"];
|
|
43
|
+
function isTrustedOrigin(origin) {
|
|
44
|
+
return TRUSTED_ORIGINS.indexOf(origin) !== -1 ||
|
|
45
|
+
(origin.startsWith("https://") && origin.endsWith(".deco.cx")) ||
|
|
46
|
+
origin === window.location.origin;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
var LIVE = JSON.parse(document.getElementById("__DECO_STATE")?.textContent || "{}");
|
|
50
|
+
window.LIVE = { ...window.LIVE, ...LIVE };
|
|
51
|
+
|
|
52
|
+
window.addEventListener("message", function(event) {
|
|
53
|
+
if (!isTrustedOrigin(event.origin)) return;
|
|
54
|
+
|
|
55
|
+
var data = event.data;
|
|
56
|
+
if (!data || typeof data !== "object") return;
|
|
57
|
+
|
|
58
|
+
switch (data.type) {
|
|
59
|
+
case "editor::inject":
|
|
60
|
+
if (data.args && data.args.script) {
|
|
61
|
+
try { eval(data.args.script); } catch(e) { console.error("[deco] inject error:", e); }
|
|
62
|
+
}
|
|
63
|
+
break;
|
|
64
|
+
|
|
65
|
+
case "scrollToComponent":
|
|
66
|
+
var el = document.querySelector('[data-manifest-key="' + CSS.escape(data.args?.id || "") + '"]');
|
|
67
|
+
if (!el) el = document.getElementById(data.args?.id);
|
|
68
|
+
if (el) el.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
69
|
+
break;
|
|
70
|
+
|
|
71
|
+
case "DOMInspector":
|
|
72
|
+
break;
|
|
73
|
+
|
|
74
|
+
case "editor::rerender":
|
|
75
|
+
if (data.args?.url) {
|
|
76
|
+
try {
|
|
77
|
+
var targetUrl = new URL(data.args.url, window.location.origin);
|
|
78
|
+
if (targetUrl.origin === window.location.origin) {
|
|
79
|
+
window.location.href = targetUrl.href;
|
|
80
|
+
}
|
|
81
|
+
} catch(e) {}
|
|
82
|
+
}
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
if (window.self === window.top) {
|
|
88
|
+
document.body.addEventListener("keydown", function(e) {
|
|
89
|
+
if (e.target !== document.body) return;
|
|
90
|
+
if (e.defaultPrevented) return;
|
|
91
|
+
|
|
92
|
+
if (
|
|
93
|
+
(e.ctrlKey && e.shiftKey && e.key === "E") ||
|
|
94
|
+
e.key === "."
|
|
95
|
+
) {
|
|
96
|
+
e.preventDefault();
|
|
97
|
+
e.stopPropagation();
|
|
98
|
+
|
|
99
|
+
var siteName = (window.LIVE.site && window.LIVE.site.name) || window.LIVE.site || "storefront";
|
|
100
|
+
var pageId = (window.LIVE.page && window.LIVE.page.id) || "";
|
|
101
|
+
var pathTemplate = (window.LIVE.page && window.LIVE.page.pathTemplate) || "/*";
|
|
102
|
+
|
|
103
|
+
var href = new URL("/choose-editor", "https://admin.deco.cx");
|
|
104
|
+
href.searchParams.set("site", siteName);
|
|
105
|
+
href.searchParams.set("domain", window.location.origin);
|
|
106
|
+
if (pageId) href.searchParams.set("pageId", pageId);
|
|
107
|
+
href.searchParams.set("path", encodeURIComponent(window.location.pathname + window.location.search));
|
|
108
|
+
href.searchParams.set("pathTemplate", encodeURIComponent(pathTemplate));
|
|
109
|
+
|
|
110
|
+
if ((e.ctrlKey || e.metaKey) && e.key === ".") {
|
|
111
|
+
window.open(href.toString(), "_blank");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
window.location.href = href.toString();
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
})();
|
|
119
|
+
`;
|
|
120
|
+
|
|
121
|
+
return <script type="module" dangerouslySetInnerHTML={{ __html: script }} />;
|
|
122
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { renderToString } from "react-dom/server";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { registerSection, registerSectionsSync } from "../cms/registry";
|
|
4
|
+
import RenderSection from "./RenderSection";
|
|
5
|
+
|
|
6
|
+
// NOTE: each test uses its OWN section key. The registry module captures the
|
|
7
|
+
// `globalThis.__deco.*` objects at module load, so re-assigning them in a
|
|
8
|
+
// beforeEach (the registry.test.ts pattern) does not actually clear the
|
|
9
|
+
// registry this module writes to — and RenderSection additionally keeps a
|
|
10
|
+
// module-level lazy cache keyed by resolveType. Unique keys sidestep both.
|
|
11
|
+
|
|
12
|
+
function Hero({ label }: { label?: string }) {
|
|
13
|
+
// Single text node — avoids React's `<!-- -->` separator between
|
|
14
|
+
// adjacent text/expression children, keeping assertions simple.
|
|
15
|
+
return <h1>{`hero-${label ?? "none"}`}</h1>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// A lazy loader that never settles: the lazy path suspends forever, so any
|
|
19
|
+
// content that DOES render must have come from the sync registry.
|
|
20
|
+
const neverResolves = () => new Promise<never>(() => {});
|
|
21
|
+
|
|
22
|
+
describe("RenderSection sync-first", () => {
|
|
23
|
+
it("renders a sync-registered section directly (no Suspense fallback)", () => {
|
|
24
|
+
const KEY = "site/sections/SyncA.tsx";
|
|
25
|
+
registerSectionsSync({ [KEY]: { default: Hero } });
|
|
26
|
+
|
|
27
|
+
const html = renderToString(<RenderSection section={{ __resolveType: KEY, label: "a" }} />);
|
|
28
|
+
expect(html).toContain("hero-a");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("renders sync for the old { Component: string } shape too", () => {
|
|
32
|
+
const KEY = "site/sections/SyncB.tsx";
|
|
33
|
+
registerSectionsSync({ [KEY]: { default: Hero } });
|
|
34
|
+
|
|
35
|
+
const html = renderToString(
|
|
36
|
+
<RenderSection section={{ Component: KEY as any, props: { label: "b" } }} />,
|
|
37
|
+
);
|
|
38
|
+
expect(html).toContain("hero-b");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("prefers the sync component over a pending lazy loader", () => {
|
|
42
|
+
const KEY = "site/sections/SyncC.tsx";
|
|
43
|
+
// Both registered: lazy loader never settles, sync is available.
|
|
44
|
+
// Sync-first must render the content without suspending.
|
|
45
|
+
registerSection(KEY, neverResolves as any);
|
|
46
|
+
registerSectionsSync({ [KEY]: { default: Hero } });
|
|
47
|
+
|
|
48
|
+
const html = renderToString(
|
|
49
|
+
<RenderSection
|
|
50
|
+
section={{ __resolveType: KEY, label: "c" }}
|
|
51
|
+
fallback={<span>loading</span>}
|
|
52
|
+
/>,
|
|
53
|
+
);
|
|
54
|
+
expect(html).toContain("hero-c");
|
|
55
|
+
expect(html).not.toContain("loading");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("falls back to the lazy path when no sync component is registered", () => {
|
|
59
|
+
const KEY = "site/sections/LazyOnlyD.tsx";
|
|
60
|
+
registerSection(KEY, neverResolves as any);
|
|
61
|
+
|
|
62
|
+
const html = renderToString(
|
|
63
|
+
<RenderSection
|
|
64
|
+
section={{ __resolveType: KEY, label: "d" }}
|
|
65
|
+
fallback={<span>loading</span>}
|
|
66
|
+
/>,
|
|
67
|
+
);
|
|
68
|
+
expect(html).toContain("loading");
|
|
69
|
+
expect(html).not.toContain("hero-d");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("renders the fallback when the section is not registered at all", () => {
|
|
73
|
+
const html = renderToString(
|
|
74
|
+
<RenderSection
|
|
75
|
+
section={{ __resolveType: "site/sections/MissingE.tsx" }}
|
|
76
|
+
fallback={<span>missing</span>}
|
|
77
|
+
/>,
|
|
78
|
+
);
|
|
79
|
+
expect(html).toContain("missing");
|
|
80
|
+
});
|
|
81
|
+
});
|