@decocms/blocks 7.3.0 → 7.4.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.3.0",
3
+ "version": "7.4.0",
4
4
  "type": "module",
5
5
  "description": "Deco framework-agnostic core: CMS block resolution, section registry, matchers, portable SDK utilities",
6
6
  "repository": {
@@ -46,6 +46,7 @@
46
46
  "./sdk/cookie": "./src/sdk/cookie.ts",
47
47
  "./sdk/crypto": "./src/sdk/crypto.ts",
48
48
  "./sdk/csp": "./src/sdk/csp.ts",
49
+ "./sdk/detectDevice": "./src/sdk/detectDevice.ts",
49
50
  "./sdk/djb2": "./src/sdk/djb2.ts",
50
51
  "./sdk/encoding": "./src/sdk/encoding.ts",
51
52
  "./sdk/env": "./src/sdk/env.ts",
@@ -16,7 +16,7 @@
16
16
  * });
17
17
  * ```
18
18
  */
19
- import { detectDevice } from "../sdk/useDevice";
19
+ import { detectDevice } from "../sdk/detectDevice";
20
20
  import type { SectionLoaderFn } from "./sectionLoaders";
21
21
 
22
22
  /**
@@ -1,5 +1,18 @@
1
+ "use client";
2
+
3
+ // `"use client"` added by @decocms/nextjs's next-smoke fixture build
4
+ // (Picture is reachable from `DecoRootLayout`/`SectionRenderer` via the
5
+ // `hooks/index.ts` barrel, so it is part of the react-server module graph
6
+ // on any Next.js Server Component page even when a given page never
7
+ // renders it): `createContext`/`useContext` are unavailable under React's
8
+ // react-server condition, and Next statically flags any reachable module
9
+ // that imports them without this directive ("You're importing a component
10
+ // that needs createContext...") — same class of fix, same precedent, as
11
+ // `SectionErrorFallback.tsx`'s `"use client"` (class component /
12
+ // componentDidCatch, also client-only).
1
13
  import {
2
14
  type ComponentPropsWithoutRef,
15
+ type Context,
3
16
  createContext,
4
17
  forwardRef,
5
18
  type ReactNode,
@@ -12,13 +25,22 @@ import { type FitOptions, getOptimizedMediaUrl, getSrcSet } from "./Image";
12
25
  // Preload context — flows from <Picture preload> to child <Source> elements
13
26
  // so each source can inject its own <link rel="preload"> with the correct
14
27
  // media query for responsive art direction.
28
+ //
29
+ // Still created LAZILY (first render, not module scope) even under
30
+ // `"use client"`: this keeps a single memoized context object regardless of
31
+ // how many client bundle chunks end up importing this module, rather than
32
+ // relying on module-instance identity across chunks.
15
33
  // -------------------------------------------------------------------------
16
34
 
17
35
  interface PreloadContextValue {
18
36
  preload: boolean;
19
37
  }
20
38
 
21
- const PreloadContext = createContext<PreloadContextValue>({ preload: false });
39
+ let _preloadContext: Context<PreloadContextValue> | null = null;
40
+ function getPreloadContext(): Context<PreloadContextValue> {
41
+ _preloadContext ??= createContext<PreloadContextValue>({ preload: false });
42
+ return _preloadContext;
43
+ }
22
44
 
23
45
  // -------------------------------------------------------------------------
24
46
  // Source — composable <source> with automatic srcSet optimization and
@@ -41,7 +63,7 @@ export const Source = forwardRef<HTMLSourceElement, SourceProps>(function Source
41
63
  { src, width, height, fetchPriority, fit = "cover", ...rest },
42
64
  ref,
43
65
  ) {
44
- const { preload } = useContext(PreloadContext);
66
+ const { preload } = useContext(getPreloadContext());
45
67
 
46
68
  const optimizedSrc = getOptimizedMediaUrl({
47
69
  originalSrc: src,
@@ -93,6 +115,7 @@ export const Picture = forwardRef<HTMLPictureElement, PictureProps>(function Pic
93
115
  ref,
94
116
  ) {
95
117
  const value = useMemo(() => ({ preload }), [preload]);
118
+ const PreloadContext = getPreloadContext();
96
119
 
97
120
  return (
98
121
  <PreloadContext.Provider value={value}>
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Pure User-Agent device detection — no React, no RequestContext, no
3
+ * side effects. Import from HERE (not `./useDevice`) in any module that can
4
+ * end up in a Next.js App Router **route handler** graph.
5
+ *
6
+ * Why this file exists: `useDevice.ts` re-exports the `"use client"`
7
+ * context half (`./useDeviceContext`, which calls `createContext` at module
8
+ * scope) for backward compatibility. That re-export is harmless in Server
9
+ * Components — Next honors the `"use client"` boundary there — but route
10
+ * handlers (App Router route.ts files) have NO client graph: the directive
11
+ * is ignored, the module executes against Next's vendored **RSC** React
12
+ * (`vendored/rsc/react.js`), and that build has no `createContext`, so the
13
+ * whole route module crashes at evaluation time
14
+ * ("...createContext is not a function"). `@decocms/nextjs`'s admin route
15
+ * handlers reach device detection via `cms/sectionMixins.ts`
16
+ * (`detectDevice`) and `sdk/requestContext.ts` (`isMobileUA`) — both now
17
+ * import this leaf so their graphs never touch the client context file.
18
+ */
19
+
20
+ export type Device = "mobile" | "tablet" | "desktop";
21
+
22
+ // Android phones include "Mobile" in their UA; Android tablets do not.
23
+ // Check TABLET_RE first so `android(?!.*mobile)` captures tablets before
24
+ // the MOBILE_RE `android.*mobile` branch matches phones.
25
+ export const MOBILE_RE = /mobile|android.*mobile|iphone|ipod|webos|blackberry|opera mini|iemobile/i;
26
+ export const TABLET_RE = /ipad|tablet|kindle|silk|playbook|android(?!.*mobile)/i;
27
+
28
+ /**
29
+ * Simple mobile-or-not check (mobile + tablet = true).
30
+ * Use this for cache key splitting or any context where you
31
+ * only need a mobile/desktop binary decision.
32
+ */
33
+ export function isMobileUA(userAgent: string): boolean {
34
+ return MOBILE_RE.test(userAgent) || TABLET_RE.test(userAgent);
35
+ }
36
+
37
+ /**
38
+ * Detect device type from a User-Agent string.
39
+ * Pure function — no side effects, works anywhere.
40
+ */
41
+ export function detectDevice(userAgent: string): Device {
42
+ if (TABLET_RE.test(userAgent)) return "tablet";
43
+ if (MOBILE_RE.test(userAgent)) return "mobile";
44
+ return "desktop";
45
+ }
@@ -0,0 +1,33 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ // isDevMode() memoises its result in module-level state after the first
4
+ // call, so each case below resets modules and re-imports fresh to get an
5
+ // unmemoised read for the NODE_ENV value it's stubbing.
6
+ describe("isDevMode", () => {
7
+ afterEach(() => {
8
+ vi.unstubAllEnvs();
9
+ });
10
+
11
+ it("is false when NODE_ENV is production", async () => {
12
+ vi.stubEnv("NODE_ENV", "production");
13
+ vi.stubEnv("DECO_PREVIEW", "");
14
+ vi.resetModules();
15
+ const { isDevMode } = await import("./env");
16
+ expect(isDevMode()).toBe(false);
17
+ });
18
+
19
+ it("is true when NODE_ENV is development", async () => {
20
+ vi.stubEnv("NODE_ENV", "development");
21
+ vi.resetModules();
22
+ const { isDevMode } = await import("./env");
23
+ expect(isDevMode()).toBe(true);
24
+ });
25
+
26
+ it("is true when DECO_PREVIEW=true even if NODE_ENV is production", async () => {
27
+ vi.stubEnv("NODE_ENV", "production");
28
+ vi.stubEnv("DECO_PREVIEW", "true");
29
+ vi.resetModules();
30
+ const { isDevMode } = await import("./env");
31
+ expect(isDevMode()).toBe(true);
32
+ });
33
+ });
package/src/sdk/env.ts CHANGED
@@ -7,12 +7,37 @@
7
7
 
8
8
  let _isDev: boolean | null = null;
9
9
 
10
+ /**
11
+ * Reads `process.env.NODE_ENV` defensively.
12
+ *
13
+ * Wrapped in try/catch so the bare `process` reference can't throw in runtimes
14
+ * without a `process` global (workerd without `nodejs_compat`). Bundlers that
15
+ * statically define `process.env.NODE_ENV` (Vite/esbuild in Workers builds —
16
+ * the same role the old `import.meta.env.DEV` signal served) replace this
17
+ * expression with a string constant at build time, so no runtime `process`
18
+ * global is needed in that path at all.
19
+ */
20
+ function readNodeEnv(): string | undefined {
21
+ try {
22
+ return process.env.NODE_ENV;
23
+ } catch {
24
+ return undefined;
25
+ }
26
+ }
27
+
10
28
  /**
11
29
  * Returns `true` when running in a development environment.
12
30
  *
13
31
  * Detection order:
14
- * 1. `import.meta.env.DEV` Vite build-time constant (reliable in Workers/Miniflare)
15
- * 2. `NODE_ENV=development` standard Node/Vite convention
32
+ * 1. `readNodeEnv() === "development"` bundler-define-friendly read of
33
+ * `process.env.NODE_ENV` (see `readNodeEnv` above). This replaces the
34
+ * previous `import.meta.env.DEV` signal: `import.meta` is ESM-only syntax
35
+ * and is a hard syntax error when CJS consumers (ts-jest) compile this
36
+ * raw-TS package, and `env.ts` is reachable via the `@decocms/blocks/sdk`
37
+ * barrel, so any such consumer importing the barrel would fail to compile.
38
+ * 2. `NODE_ENV=development` read off `globalThis.process.env` — standard
39
+ * Node/Vite convention, for runtimes where `process` is a real global.
40
+ * 3. `DECO_PREVIEW=true` — explicit preview-mode override.
16
41
  *
17
42
  * The result is memoised after the first evaluation.
18
43
  */
@@ -21,11 +46,9 @@ export function isDevMode(): boolean {
21
46
 
22
47
  const env = typeof globalThis.process !== "undefined" ? globalThis.process.env : undefined;
23
48
 
24
- // Vite statically replaces import.meta.env.DEV at build time (true in dev, false in prod).
25
- // In Miniflare/Workers, process.env is unavailable, so this is the reliable signal.
26
- const vitaDev = !!(import.meta as unknown as { env?: { DEV?: boolean } }).env?.DEV;
49
+ const nodeEnvDev = readNodeEnv() === "development";
27
50
 
28
- _isDev = vitaDev || env?.NODE_ENV === "development" || env?.DECO_PREVIEW === "true";
51
+ _isDev = nodeEnvDev || env?.NODE_ENV === "development" || env?.DECO_PREVIEW === "true";
29
52
 
30
53
  return _isDev;
31
54
  }
@@ -87,7 +87,7 @@ export interface RequestContextData {
87
87
  // Storage
88
88
  // -------------------------------------------------------------------------
89
89
 
90
- import { isMobileUA } from "./useDevice";
90
+ import { isMobileUA } from "./detectDevice";
91
91
 
92
92
  const BOT_RE =
93
93
  /bot|crawl|spider|slurp|bingpreview|facebookexternalhit|linkedinbot|twitterbot|whatsapp|telegram|googlebot|yandex|baidu|duckduck/i;
@@ -21,52 +21,32 @@
21
21
  * ```
22
22
  */
23
23
 
24
+ import { detectDevice } from "./detectDevice";
24
25
  import { RequestContext } from "./requestContext";
25
26
 
26
- export type Device = "mobile" | "tablet" | "desktop";
27
+ // The pure UA-parsing half (`Device`, `MOBILE_RE`, `TABLET_RE`,
28
+ // `isMobileUA`, `detectDevice`) lives in `./detectDevice` — a leaf with no
29
+ // React and no RequestContext — and is re-exported below for backward
30
+ // compatibility. Modules that can land in a Next.js route-handler graph
31
+ // (`cms/sectionMixins.ts`, `sdk/requestContext.ts`) MUST import that leaf
32
+ // directly, not this file: this file also re-exports the `"use client"`
33
+ // context half, which crashes route handlers (see ./detectDevice's doc
34
+ // comment for the vendored-RSC-React mechanics).
35
+ export { type Device, detectDevice, isMobileUA, MOBILE_RE, TABLET_RE } from "./detectDevice";
27
36
 
28
37
  // `DeviceContext`, the `useDevice()` hook, and `DeviceProvider` live in
29
38
  // `./useDeviceContext` (a `"use client"` file) and are re-exported below for
30
39
  // full backward compatibility with this module's public API — see that
31
- // file's doc comment for why the split exists. Everything that stays in
32
- // *this* file is a plain, hook-free function, safe to import from
33
- // server-only code (e.g. `cms/sectionMixins.ts`'s `withDevice()`/
34
- // `withMobile()` loader mixins) without dragging a client-only hook/context
35
- // boundary into a Server Component's module graph.
40
+ // file's doc comment for why the split exists.
36
41
  export { DeviceContext, useDevice, DeviceProvider } from "./useDeviceContext";
37
42
 
38
- // Android phones include "Mobile" in their UA; Android tablets do not.
39
- // Check TABLET_RE first so `android(?!.*mobile)` captures tablets before
40
- // the MOBILE_RE `android.*mobile` branch matches phones.
41
- export const MOBILE_RE = /mobile|android.*mobile|iphone|ipod|webos|blackberry|opera mini|iemobile/i;
42
- export const TABLET_RE = /ipad|tablet|kindle|silk|playbook|android(?!.*mobile)/i;
43
-
44
- /**
45
- * Simple mobile-or-not check (mobile + tablet = true).
46
- * Use this for cache key splitting or any context where you
47
- * only need a mobile/desktop binary decision.
48
- */
49
- export function isMobileUA(userAgent: string): boolean {
50
- return MOBILE_RE.test(userAgent) || TABLET_RE.test(userAgent);
51
- }
52
-
53
- /**
54
- * Detect device type from a User-Agent string.
55
- * Pure function — no side effects, works anywhere.
56
- */
57
- export function detectDevice(userAgent: string): Device {
58
- if (TABLET_RE.test(userAgent)) return "tablet";
59
- if (MOBILE_RE.test(userAgent)) return "mobile";
60
- return "desktop";
61
- }
62
-
63
43
  /**
64
44
  * Resolve the current device from the ambient runtime (RequestContext on the
65
45
  * server, `navigator.userAgent` on the client) — no React hooks involved.
66
46
  * Used both by the plain `check*()` helpers below and by `useDevice()`/
67
47
  * `DeviceProvider` in `./useDeviceContext`.
68
48
  */
69
- export function resolveDeviceFromRuntime(): Device {
49
+ export function resolveDeviceFromRuntime(): ReturnType<typeof detectDevice> {
70
50
  // Server: use RequestContext UA header
71
51
  if (typeof document === "undefined") {
72
52
  const ctx = RequestContext.current;