@decocms/nextjs 7.31.2 → 7.31.3

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/nextjs",
3
- "version": "7.31.2",
3
+ "version": "7.31.3",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -29,8 +29,8 @@
29
29
  "lint:unused": "knip"
30
30
  },
31
31
  "dependencies": {
32
- "@decocms/blocks": "7.31.2",
33
- "@decocms/blocks-admin": "7.31.2"
32
+ "@decocms/blocks": "7.31.3",
33
+ "@decocms/blocks-admin": "7.31.3"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "next": ">=15.0.0",
@@ -1,32 +1,67 @@
1
+ import { act } from "react";
2
+ import { createRoot, type Root } from "react-dom/client";
1
3
  import { renderToString } from "react-dom/server";
2
- import { afterEach, describe, expect, it } from "vitest";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
3
5
 
4
6
  import { DraftPreviewBadge } from "./DraftPreviewBadge";
5
7
 
6
8
  describe("DraftPreviewBadge (render)", () => {
7
- it("surfaces the preview-mode label so a reviewer can't mistake a draft for live", () => {
9
+ // Raw SSR (react-dom/server) never runs effects — this is what a consumer
10
+ // site that never hydrates this component, or the server's own render
11
+ // pass, actually ships. The badge fails closed: nothing here, by design
12
+ // (see the module doc for why render-then-hide is worse).
13
+ it("renders nothing without a client to run the reveal effect", () => {
8
14
  const html = renderToString(<DraftPreviewBadge pointer="fila.vtex.app@v1" />);
9
- expect(html).toContain("Preview mode");
15
+ expect(html).toBe("");
10
16
  });
11
17
 
12
- it("embeds the deco mark inline (no external asset that could 404 on a consumer site)", () => {
13
- const html = renderToString(<DraftPreviewBadge pointer="fila.vtex.app@v1" />);
14
- expect(html).toContain("data:image/png;base64,");
15
- });
18
+ describe("mounted in a browser", () => {
19
+ let container: HTMLDivElement;
20
+ let root: Root;
21
+
22
+ beforeEach(() => {
23
+ container = document.createElement("div");
24
+ document.body.appendChild(container);
25
+ root = createRoot(container);
26
+ });
16
27
 
17
- describe("embedded in an iframe", () => {
18
- const realTop = window.top;
19
28
  afterEach(() => {
20
- Object.defineProperty(window, "top", {
21
- value: realTop,
22
- configurable: true,
29
+ act(() => {
30
+ root.unmount();
31
+ });
32
+ container.remove();
33
+ });
34
+
35
+ it("reveals the preview-mode label after mount, once confirmed unframed", () => {
36
+ act(() => {
37
+ root.render(<DraftPreviewBadge pointer="fila.vtex.app@v1" />);
23
38
  });
39
+ expect(container.textContent).toContain("Preview mode");
24
40
  });
25
41
 
26
- it("renders nothing — Studio's own preview surface already has its own chrome", () => {
27
- Object.defineProperty(window, "top", { value: {}, configurable: true });
28
- const html = renderToString(<DraftPreviewBadge pointer="fila.vtex.app@v1" />);
29
- expect(html).toBe("");
42
+ it("embeds the deco mark inline (no external asset that could 404 on a consumer site)", () => {
43
+ act(() => {
44
+ root.render(<DraftPreviewBadge pointer="fila.vtex.app@v1" />);
45
+ });
46
+ expect(container.innerHTML).toContain("data:image/png;base64,");
47
+ });
48
+
49
+ describe("embedded in an iframe", () => {
50
+ const realTop = window.top;
51
+ afterEach(() => {
52
+ Object.defineProperty(window, "top", {
53
+ value: realTop,
54
+ configurable: true,
55
+ });
56
+ });
57
+
58
+ it("stays hidden — Studio's own preview surface already has its own chrome", () => {
59
+ Object.defineProperty(window, "top", { value: {}, configurable: true });
60
+ act(() => {
61
+ root.render(<DraftPreviewBadge pointer="fila.vtex.app@v1" />);
62
+ });
63
+ expect(container.textContent).toBe("");
64
+ });
30
65
  });
31
66
  });
32
67
  });
@@ -22,6 +22,15 @@
22
22
  * inside that frame would be redundant clutter, not a signal a reviewer
23
23
  * needs. A reviewer following a shared preview link in their own top-level
24
24
  * tab still sees it; only the embedded case is suppressed.
25
+ *
26
+ * Renders NOTHING until a client-side effect confirms it's safe to show —
27
+ * fails closed, not open. `isFramed()` needs `window`, so it can only ever
28
+ * be evaluated in the browser; rendering the badge by default and hiding it
29
+ * once framing is detected would flash it for a beat inside Studio's own
30
+ * iframe before it disappeared, and would hydration-mismatch (server has no
31
+ * `window`, so it would always render the "unframed" branch). Starting
32
+ * hidden on both the server and the client's matching first render, then
33
+ * revealing via `useEffect` only when confirmed unframed, avoids both.
25
34
  */
26
35
  import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
27
36
  import { DECO_MARK_DATA_URI } from "./decoMark";
@@ -60,10 +69,10 @@ export function buildExitUrl(href: string): string {
60
69
  * Whether this render is embedded in another page's iframe — Studio's own
61
70
  * preview surface, primarily. `window.top !== window` is a same-origin
62
71
  * reference comparison, so it never throws cross-origin (unlike reading a
63
- * cross-origin frame's properties). No `window` (SSR, or a consumer site
64
- * that never hydrates this component) reports not-framed — fails open,
65
- * matching the badge's "renders the same on any consumer site" guarantee.
66
- * Pure and exported for tests.
72
+ * cross-origin frame's properties). No `window` reports not-framed — a safe
73
+ * default for the function itself, though the badge only ever calls this
74
+ * from a `useEffect`, where `window` always exists. Pure and exported for
75
+ * tests.
67
76
  */
68
77
  export function isFramed(win: typeof window | undefined = globalThis.window): boolean {
69
78
  return typeof win !== "undefined" && win.top !== win;
@@ -119,8 +128,18 @@ function ShareIcon() {
119
128
  export function DraftPreviewBadge({ pointer }: DraftPreviewBadgeProps) {
120
129
  const [open, setOpen] = useState(false);
121
130
  const [copied, setCopied] = useState(false);
131
+ const [visible, setVisible] = useState(false);
122
132
  const rootRef = useRef<HTMLDivElement>(null);
123
133
 
134
+ // Reveal only after mount, and only when confirmed unframed — fails
135
+ // closed. Server and client's matching first render both start at this
136
+ // same `false`, so there's no hydration mismatch; this effect (client
137
+ // only, runs once) is the sole place `isFramed()` is ever evaluated. See
138
+ // the module doc for why the alternative (render then hide) is worse.
139
+ useEffect(() => {
140
+ if (!isFramed()) setVisible(true);
141
+ }, []);
142
+
124
143
  // Dismiss on outside click or Escape — standard popover behaviour, and it
125
144
  // keeps the badge from trapping focus on a reviewer's own page.
126
145
  useEffect(() => {
@@ -157,11 +176,7 @@ export function DraftPreviewBadge({ pointer }: DraftPreviewBadgeProps) {
157
176
  }
158
177
  }, [pointer]);
159
178
 
160
- // Checked inline, not cached in state behind an effect: it's a free
161
- // reference compare, and computing it during render means the client's
162
- // first (hydration) pass already agrees with what it will show — no
163
- // post-hydration flash for the common (unframed) case.
164
- if (isFramed()) return null;
179
+ if (!visible) return null;
165
180
 
166
181
  return (
167
182
  <div