@delmaredigital/payload-puck 0.6.30 → 0.7.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/README.md CHANGED
@@ -32,7 +32,8 @@ pnpm add @delmaredigital/payload-puck @puckeditor/core
32
32
 
33
33
  | Dependency | Version |
34
34
  |------------|---------|
35
- | `@puckeditor/core` | >= 0.21.0 |
35
+ | `node` | >= 20.9.0 |
36
+ | `@puckeditor/core` | >= 0.23.0 |
36
37
  | `payload` | >= 3.69.0 |
37
38
  | `@payloadcms/next` | >= 3.69.0 |
38
39
  | `next` | >= 15.4.8 (see security note below) |
@@ -42,6 +43,19 @@ pnpm add @delmaredigital/payload-puck @puckeditor/core
42
43
 
43
44
  > **Security:** If your app uses Next.js middleware (or proxy.ts) to protect dynamic routes, use `next` >= 15.5.16 / 16.2.5 to pick up the fix for [CVE-2026-44574](https://github.com/vercel/next.js/security/advisories/GHSA-492v-c6pp-mqqv) (middleware bypass via dynamic route parameter injection). Turbopack users need >= 15.5.18 / 16.2.6.
44
45
 
46
+ ### Upgrading to 0.7.0 (breaking)
47
+
48
+ `0.7.0` raises two floors. Both are a one-line change for most projects:
49
+
50
+ ```bash
51
+ pnpm add @puckeditor/core@^0.23.0 # peer floor moved from >=0.21.0
52
+ ```
53
+
54
+ - **`@puckeditor/core` now requires >= 0.23.0.** The Puck plugins this package bundles are versioned in lockstep with Puck core and import it from the host, so running them against an older core is not a supported combination. Puck 0.23 ships a rewritten canvas drag-and-drop engine and a redesigned outline — **the editing experience changes visibly**, even though no API you call has changed. Worth a pass through your editor after upgrading. See the [Puck 0.23 release notes](https://puckeditor.com/blog/puck-023).
55
+ - **Node 18 is no longer supported;** the floor is now `>=20.9.0`. Node 18 is end-of-life and Puck core 0.23 itself requires `>=20.0.0`.
56
+
57
+ No exports, props, or configuration options were removed or renamed. Full detail in the [changelog](./CHANGELOG.md).
58
+
45
59
  ---
46
60
 
47
61
  ## Quick Start
@@ -16,6 +16,7 @@ import { IframeWrapper } from './components/IframeWrapper.js';
16
16
  import { PreviewModal } from './components/PreviewModal.js';
17
17
  import { DarkModeStyles } from './components/DarkModeStyles.js';
18
18
  import { useUnsavedChanges } from './hooks/useUnsavedChanges.js';
19
+ import { isDataEqual } from './utils/isDataEqual.js';
19
20
  import { createVersionHistoryPlugin } from './plugins/versionHistoryPlugin.js';
20
21
  import { ThemeProvider } from '../theme/index.js';
21
22
  import { usePuckConfig } from '../views/PuckConfigContext.js';
@@ -117,6 +118,11 @@ import { useAiPrompts } from '../ai/hooks/useAiPrompts.js';
117
118
  ]);
118
119
  // Use a ref to track latest data without causing re-renders
119
120
  const latestDataRef = useRef(dataWithSlug);
121
+ // Track the last loaded/saved data so we can distinguish real user edits from
122
+ // no-op onChange dispatches (e.g. Puck's mount-time resolve pass). Refreshed at
123
+ // every markClean() site (save / publish) so subsequent edits diff against the
124
+ // most recently persisted state.
125
+ const savedDataRef = useRef(dataWithSlug);
120
126
  // Get editor stylesheets from PuckConfigProvider context (as fallback)
121
127
  const { editorStylesheets: contextStylesheets, editorCss: contextCss } = usePuckConfig();
122
128
  // Props take precedence over context
@@ -239,6 +245,7 @@ import { useAiPrompts } from '../ai/hooks/useAiPrompts.js';
239
245
  setSaveError(null); // Clear any previous error
240
246
  // After saving as draft, update status to draft (shows "Unpublished Changes" if was published)
241
247
  setDocumentStatus('draft');
248
+ savedDataRef.current = typedData;
242
249
  markClean();
243
250
  onSaveSuccess?.(data);
244
251
  } catch (error) {
@@ -287,6 +294,7 @@ import { useAiPrompts } from '../ai/hooks/useAiPrompts.js';
287
294
  setSaveError(null); // Clear any previous error
288
295
  setDocumentStatus('published'); // Update status after successful publish
289
296
  setWasPublished(true); // Mark as having been published
297
+ savedDataRef.current = typedData;
290
298
  markClean();
291
299
  onSaveSuccess?.(data);
292
300
  } catch (error) {
@@ -340,9 +348,19 @@ import { useAiPrompts } from '../ai/hooks/useAiPrompts.js';
340
348
  // Handle data change
341
349
  const handleChange = useCallback((data)=>{
342
350
  latestDataRef.current = data;
343
- markDirty();
351
+ // Only mark dirty when the data actually differs from the last loaded/saved
352
+ // state. Puck fires onChange for no-op mount-time resolves (which can differ
353
+ // from the loaded data only by undefined-valued keys); treating those as
354
+ // edits produces a spurious "Unsaved" flag on load. isDataEqual is
355
+ // undefined-tolerant so those resolves compare equal and clear the flag.
356
+ if (isDataEqual(data, savedDataRef.current)) {
357
+ markClean();
358
+ } else {
359
+ markDirty();
360
+ }
344
361
  onChangeProp?.(data);
345
362
  }, [
363
+ markClean,
346
364
  markDirty,
347
365
  onChangeProp
348
366
  ]);
@@ -415,6 +433,7 @@ import { useAiPrompts } from '../ai/hooks/useAiPrompts.js';
415
433
  setLastSaved(new Date());
416
434
  setSaveError(null);
417
435
  setDocumentStatus('draft');
436
+ savedDataRef.current = data;
418
437
  markClean();
419
438
  onSaveSuccess?.(data);
420
439
  } catch (error) {
@@ -505,18 +524,32 @@ import { useAiPrompts } from '../ai/hooks/useAiPrompts.js';
505
524
  const defaultPlugins = [
506
525
  headingAnalyzer
507
526
  ];
527
+ // Restoring a version has already persisted that data server-side, so it
528
+ // becomes the new saved baseline. Without this, the `setData` dispatch the
529
+ // restore performs fires an onChange that diffs against the pre-restore
530
+ // baseline and immediately re-flags the (already saved) document as dirty.
531
+ const handleRestoreSuccess = useCallback((restoredData)=>{
532
+ if (restoredData) {
533
+ const typedData = restoredData;
534
+ savedDataRef.current = typedData;
535
+ latestDataRef.current = typedData;
536
+ }
537
+ markClean();
538
+ }, [
539
+ markClean
540
+ ]);
508
541
  // Version history plugin for the plugin rail
509
542
  const versionHistoryPlugin = useMemo(()=>{
510
543
  if (!pageId) return null;
511
544
  return createVersionHistoryPlugin({
512
545
  pageId,
513
546
  apiEndpoint,
514
- onRestoreSuccess: markClean
547
+ onRestoreSuccess: handleRestoreSuccess
515
548
  });
516
549
  }, [
517
550
  pageId,
518
551
  apiEndpoint,
519
- markClean
552
+ handleRestoreSuccess
520
553
  ]);
521
554
  // Fetch AI prompts client-side when prompts collection is enabled
522
555
  // This allows prompts to update in real-time when edited via the prompt editor panel
@@ -29,9 +29,13 @@ export interface VersionHistoryPanelProps {
29
29
  */
30
30
  apiEndpoint?: string;
31
31
  /**
32
- * Callback after successful restore (e.g., to mark editor as clean)
32
+ * Callback after successful restore (e.g., to mark editor as clean).
33
+ *
34
+ * Receives the restored data that was dispatched into the editor, so the
35
+ * caller can treat it as the new "last saved" baseline — the restore endpoint
36
+ * has already persisted it server-side.
33
37
  */
34
- onRestoreSuccess?: () => void;
38
+ onRestoreSuccess?: (restoredData?: Data) => void;
35
39
  }
36
40
  /**
37
41
  * Version history panel for the Puck plugin rail
@@ -256,8 +256,9 @@ const styles = {
256
256
  // Show success message
257
257
  setSuccessMessage(`Restored version from ${formatDate(version.updatedAt)}`);
258
258
  setTimeout(()=>setSuccessMessage(null), 3000);
259
- // Notify parent to mark as clean
260
- onRestoreSuccess?.();
259
+ // Notify parent to mark as clean, handing over the restored data so it
260
+ // becomes the new saved baseline (the restore already persisted it).
261
+ onRestoreSuccess?.(restoredDoc?.puckData);
261
262
  // Refresh version list
262
263
  fetchVersions();
263
264
  } catch (err) {
@@ -1,4 +1,4 @@
1
- import type { Plugin } from '@puckeditor/core';
1
+ import type { Data, Plugin } from '@puckeditor/core';
2
2
  export interface VersionHistoryPluginOptions {
3
3
  /**
4
4
  * Page ID to fetch versions for
@@ -10,9 +10,10 @@ export interface VersionHistoryPluginOptions {
10
10
  */
11
11
  apiEndpoint?: string;
12
12
  /**
13
- * Callback after successful restore (e.g., to mark editor as clean)
13
+ * Callback after successful restore (e.g., to mark editor as clean).
14
+ * Receives the restored data that was dispatched into the editor.
14
15
  */
15
- onRestoreSuccess?: () => void;
16
+ onRestoreSuccess?: (restoredData?: Data) => void;
16
17
  }
17
18
  /**
18
19
  * Creates a Puck plugin for version history
@@ -1,2 +1,3 @@
1
1
  export { injectPageTreeFields } from './injectPageTreeFields.js';
2
2
  export { detectPageTree, hasPageTreeFields } from './detectPageTree.js';
3
+ export { isDataEqual } from './isDataEqual.js';
@@ -1,2 +1,3 @@
1
1
  export { injectPageTreeFields } from './injectPageTreeFields.js';
2
2
  export { detectPageTree, hasPageTreeFields } from './detectPageTree.js';
3
+ export { isDataEqual } from './isDataEqual.js';
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Undefined-tolerant deep equality for Puck data.
3
+ *
4
+ * Puck's mount-time resolve pass (`resolveAndCommitData`) can dispatch a `replace`
5
+ * even when the net content is unchanged — e.g. a resolved node differs from the
6
+ * loaded node only by an `undefined`-valued key. Strict deep-equal libraries such
7
+ * as `fast-equals` treat `{}` and `{ k: undefined }` as different, so that no-op
8
+ * resolve looks like a real edit and spuriously marks the document dirty.
9
+ *
10
+ * This comparison treats an absent key and an `undefined`-valued key as equal, so
11
+ * a resolve that only adds/removes `undefined` values compares equal to the loaded
12
+ * data. Object key order is irrelevant (as with any deep equal); array order is
13
+ * significant (Puck content order is meaningful).
14
+ */
15
+ export declare function isDataEqual(a: unknown, b: unknown): boolean;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Undefined-tolerant deep equality for Puck data.
3
+ *
4
+ * Puck's mount-time resolve pass (`resolveAndCommitData`) can dispatch a `replace`
5
+ * even when the net content is unchanged — e.g. a resolved node differs from the
6
+ * loaded node only by an `undefined`-valued key. Strict deep-equal libraries such
7
+ * as `fast-equals` treat `{}` and `{ k: undefined }` as different, so that no-op
8
+ * resolve looks like a real edit and spuriously marks the document dirty.
9
+ *
10
+ * This comparison treats an absent key and an `undefined`-valued key as equal, so
11
+ * a resolve that only adds/removes `undefined` values compares equal to the loaded
12
+ * data. Object key order is irrelevant (as with any deep equal); array order is
13
+ * significant (Puck content order is meaningful).
14
+ */ export function isDataEqual(a, b) {
15
+ if (Object.is(a, b)) {
16
+ return true;
17
+ }
18
+ // NaN is handled by Object.is above; remaining primitives that differ are unequal.
19
+ if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) {
20
+ return false;
21
+ }
22
+ const aIsArray = Array.isArray(a);
23
+ const bIsArray = Array.isArray(b);
24
+ if (aIsArray !== bIsArray) {
25
+ return false;
26
+ }
27
+ if (aIsArray && bIsArray) {
28
+ if (a.length !== b.length) {
29
+ return false;
30
+ }
31
+ for(let i = 0; i < a.length; i++){
32
+ if (!isDataEqual(a[i], b[i])) {
33
+ return false;
34
+ }
35
+ }
36
+ return true;
37
+ }
38
+ const aObj = a;
39
+ const bObj = b;
40
+ // Compare the union of keys, treating a missing key and an `undefined` value as
41
+ // equivalent so that `{}` and `{ k: undefined }` are considered equal.
42
+ const keys = new Set([
43
+ ...Object.keys(aObj),
44
+ ...Object.keys(bObj)
45
+ ]);
46
+ for (const key of keys){
47
+ const aVal = aObj[key];
48
+ const bVal = bObj[key];
49
+ if (aVal === undefined && bVal === undefined) {
50
+ continue;
51
+ }
52
+ if (!isDataEqual(aVal, bVal)) {
53
+ return false;
54
+ }
55
+ }
56
+ return true;
57
+ }
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.6.30";
1
+ export declare const VERSION = "0.7.0";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Auto-generated by scripts/generate-version.js - do not edit manually
2
- export const VERSION = '0.6.30';
2
+ export const VERSION = '0.7.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@delmaredigital/payload-puck",
3
- "version": "0.6.30",
3
+ "version": "0.7.0",
4
4
  "description": "Puck visual page builder plugin for Payload CMS",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -104,20 +104,22 @@
104
104
  "scripts": {
105
105
  "dev-publish": "pnpm build && pnpm version prerelease --preid=dev --no-git-tag-version && pnpm publish --registry http://localhost:4873 --no-git-checks --tag dev",
106
106
  "generate-version": "node scripts/generate-version.js",
107
- "prebuild": "pnpm generate-version",
108
- "build": "pnpm build:swc && pnpm build:types && pnpm copyfiles",
107
+ "build": "pnpm generate-version && pnpm build:swc && pnpm build:types && pnpm copyfiles",
109
108
  "build:swc": "swc ./src -d ./dist --strip-leading-paths",
110
109
  "build:types": "tsc --outDir dist",
111
110
  "copyfiles": "copyfiles -u 1 \"src/**/*.css\" dist",
112
111
  "dev": "pnpm generate-version && swc ./src -d ./dist --strip-leading-paths --watch",
113
- "typecheck": "tsc --noEmit",
112
+ "typecheck": "pnpm generate-version && tsc --noEmit && tsc -p tsconfig.test.json",
113
+ "test": "pnpm generate-version && vitest run",
114
+ "test:watch": "pnpm generate-version && vitest",
115
+ "test:coverage": "pnpm generate-version && vitest run --coverage",
114
116
  "clean": "rm -rf dist",
115
117
  "prepublishOnly": "pnpm build"
116
118
  },
117
119
  "peerDependencies": {
118
120
  "@payloadcms/next": ">=3.69.0",
119
121
  "@payloadcms/ui": ">=3.69.0",
120
- "@puckeditor/core": ">=0.21.0",
122
+ "@puckeditor/core": ">=0.23.0",
121
123
  "@tailwindcss/postcss": ">=4.0.0",
122
124
  "next": ">=15.4.8",
123
125
  "payload": ">=3.69.0",
@@ -152,9 +154,9 @@
152
154
  }
153
155
  },
154
156
  "dependencies": {
155
- "@puckeditor/cloud-client": "^0.7.0",
156
- "@puckeditor/plugin-ai": "^0.7.0",
157
- "@puckeditor/plugin-heading-analyzer": "^0.21.2",
157
+ "@puckeditor/cloud-client": "^0.8.2",
158
+ "@puckeditor/plugin-ai": "^0.8.2",
159
+ "@puckeditor/plugin-heading-analyzer": "^0.23.0",
158
160
  "@radix-ui/react-popover": "^1.1.15",
159
161
  "@tiptap/core": "^3.20.1",
160
162
  "@tiptap/extension-color": "^3.20.1",
@@ -171,20 +173,22 @@
171
173
  "lucide-react": "^0.469.0"
172
174
  },
173
175
  "devDependencies": {
174
- "@payloadcms/next": "^3.84.1",
175
- "@payloadcms/ui": "^3.84.1",
176
- "@puckeditor/core": "^0.22.0",
176
+ "@payloadcms/next": "^3.87.1",
177
+ "@payloadcms/ui": "^3.87.1",
178
+ "@puckeditor/core": "^0.23.0",
177
179
  "@swc/cli": "^0.6.0",
178
180
  "@swc/core": "^1.15.18",
179
181
  "@types/node": "^24.12.0",
180
182
  "@types/react": "^19.2.14",
181
183
  "@types/react-dom": "^19.2.3",
184
+ "@vitest/coverage-v8": "^3.2.7",
182
185
  "copyfiles": "^2.4.1",
183
- "next": "^16.2.6",
184
- "payload": "^3.84.1",
186
+ "next": "^16.3.0",
187
+ "payload": "^3.87.1",
185
188
  "react": "^19.2.4",
186
189
  "react-dom": "^19.2.4",
187
- "typescript": "^5.9.3"
190
+ "typescript": "^5.9.3",
191
+ "vitest": "^3.2.7"
188
192
  },
189
193
  "keywords": [
190
194
  "payload",
@@ -196,7 +200,7 @@
196
200
  "cms"
197
201
  ],
198
202
  "engines": {
199
- "node": "^18.20.2 || >=20.9.0"
203
+ "node": ">=20.9.0"
200
204
  },
201
205
  "author": "Delmare Digital",
202
206
  "repository": {