chore(live-preview): strongly types message events (#10148)

Live Preview message events were typed with the generic `MessageEvent`
interface without passing any of the Live Preview specific properties,
leading to unknown types upon use. To fix this, there is a new
`LivePreviewMessageEvent` which properly extends the underlying
`MessageEvent` interface, providing much needed type safety to these
functions. In the same vein, the `UpdatedDocument` type was not being
properly shared across packages, leading to multiple independent
definitions of this type. This type is now exported from `payload`
itself and renamed to `DocumentEvent` for improved semantics. Same with
the `FieldSchemaJSON` type. This PR also adjusts where globally scoped
variables are set, putting them within the shared `_payloadLivePreview`
namespace instead of setting them individually at the top-level.
This commit is contained in:
Jacob Fletcher
2024-12-23 13:19:52 -05:00
committed by GitHub
parent 0588394a47
commit 466f109152
12 changed files with 66 additions and 99 deletions

View File

@@ -1,33 +1,40 @@
import type { LivePreviewMessageEvent } from './types.js'
import { isLivePreviewEvent } from './isLivePreviewEvent.js' import { isLivePreviewEvent } from './isLivePreviewEvent.js'
import { mergeData } from './mergeData.js' import { mergeData } from './mergeData.js'
// For performance reasons, `fieldSchemaJSON` will only be sent once on the initial message const _payloadLivePreview = {
// We need to cache this value so that it can be used across subsequent messages /**
// To do this, save `fieldSchemaJSON` when it arrives as a global variable * For performance reasons, `fieldSchemaJSON` will only be sent once on the initial message
// Send this cached value to `mergeData`, instead of `eventData.fieldSchemaJSON` directly * We need to cache this value so that it can be used across subsequent messages
let payloadLivePreviewFieldSchema = undefined // TODO: type this from `fieldSchemaToJSON` return type * To do this, save `fieldSchemaJSON` when it arrives as a global variable
* Send this cached value to `mergeData`, instead of `eventData.fieldSchemaJSON` directly
// Each time the data is merged, cache the result as a `previousData` variable */
// This will ensure changes compound overtop of each other fieldSchema: undefined,
let payloadLivePreviewPreviousData = undefined /**
* Each time the data is merged, cache the result as a `previousData` variable
* This will ensure changes compound overtop of each other
*/
previousData: undefined,
}
export const handleMessage = async <T>(args: { export const handleMessage = async <T>(args: {
apiRoute?: string apiRoute?: string
depth?: number depth?: number
event: MessageEvent event: LivePreviewMessageEvent<T>
initialData: T initialData: T
serverURL: string serverURL: string
}): Promise<T> => { }): Promise<T> => {
const { apiRoute, depth, event, initialData, serverURL } = args const { apiRoute, depth, event, initialData, serverURL } = args
if (isLivePreviewEvent(event, serverURL)) { if (isLivePreviewEvent(event, serverURL)) {
const { data, externallyUpdatedRelationship, fieldSchemaJSON } = event.data const { data, externallyUpdatedRelationship, fieldSchemaJSON, locale } = event.data
if (!payloadLivePreviewFieldSchema && fieldSchemaJSON) { if (!_payloadLivePreview?.fieldSchema && fieldSchemaJSON) {
payloadLivePreviewFieldSchema = fieldSchemaJSON _payloadLivePreview.fieldSchema = fieldSchemaJSON
} }
if (!payloadLivePreviewFieldSchema) { if (!_payloadLivePreview?.fieldSchema) {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.warn( console.warn(
'Payload Live Preview: No `fieldSchemaJSON` was received from the parent window. Unable to merge data.', 'Payload Live Preview: No `fieldSchemaJSON` was received from the parent window. Unable to merge data.',
@@ -40,14 +47,14 @@ export const handleMessage = async <T>(args: {
apiRoute, apiRoute,
depth, depth,
externallyUpdatedRelationship, externallyUpdatedRelationship,
fieldSchema: payloadLivePreviewFieldSchema, fieldSchema: _payloadLivePreview.fieldSchema,
incomingData: data, incomingData: data,
initialData: payloadLivePreviewPreviousData || initialData, initialData: _payloadLivePreview?.previousData || initialData,
locale: event.data.locale, locale,
serverURL, serverURL,
}) })
payloadLivePreviewPreviousData = mergedData _payloadLivePreview.previousData = mergedData
return mergedData return mergedData
} }

View File

@@ -5,4 +5,5 @@ export { mergeData } from './mergeData.js'
export { ready } from './ready.js' export { ready } from './ready.js'
export { subscribe } from './subscribe.js' export { subscribe } from './subscribe.js'
export { traverseRichText } from './traverseRichText.js' export { traverseRichText } from './traverseRichText.js'
export type { LivePreviewMessageEvent } from './types.js'
export { unsubscribe } from './unsubscribe.js' export { unsubscribe } from './unsubscribe.js'

View File

@@ -1,7 +1,6 @@
import type { PaginatedDocs } from 'payload' import type { DocumentEvent, FieldSchemaJSON, PaginatedDocs } from 'payload'
import type { fieldSchemaToJSON } from 'payload/shared'
import type { PopulationsByCollection, UpdatedDocument } from './types.js' import type { PopulationsByCollection } from './types.js'
import { traverseFields } from './traverseFields.js' import { traverseFields } from './traverseFields.js'
@@ -32,8 +31,8 @@ export const mergeData = async <T>(args: {
serverURL: string serverURL: string
}) => Promise<Response> }) => Promise<Response>
depth?: number depth?: number
externallyUpdatedRelationship?: UpdatedDocument externallyUpdatedRelationship?: DocumentEvent
fieldSchema: ReturnType<typeof fieldSchemaToJSON> fieldSchema: FieldSchemaJSON
incomingData: Partial<T> incomingData: Partial<T>
initialData: T initialData: T
locale?: string locale?: string

View File

@@ -1,11 +1,12 @@
import type { DocumentEvent } from 'payload'
import type { fieldSchemaToJSON } from 'payload/shared' import type { fieldSchemaToJSON } from 'payload/shared'
import type { PopulationsByCollection, UpdatedDocument } from './types.js' import type { PopulationsByCollection } from './types.js'
import { traverseRichText } from './traverseRichText.js' import { traverseRichText } from './traverseRichText.js'
export const traverseFields = <T>(args: { export const traverseFields = <T>(args: {
externallyUpdatedRelationship?: UpdatedDocument externallyUpdatedRelationship?: DocumentEvent
fieldSchema: ReturnType<typeof fieldSchemaToJSON> fieldSchema: ReturnType<typeof fieldSchemaToJSON>
incomingData: T incomingData: T
localeChanged: boolean localeChanged: boolean

View File

@@ -1,4 +1,6 @@
import type { PopulationsByCollection, UpdatedDocument } from './types.js' import type { DocumentEvent } from 'payload'
import type { PopulationsByCollection } from './types.js'
export const traverseRichText = ({ export const traverseRichText = ({
externallyUpdatedRelationship, externallyUpdatedRelationship,
@@ -6,7 +8,7 @@ export const traverseRichText = ({
populationsByCollection, populationsByCollection,
result, result,
}: { }: {
externallyUpdatedRelationship?: UpdatedDocument externallyUpdatedRelationship?: DocumentEvent
incomingData: any incomingData: any
populationsByCollection: PopulationsByCollection populationsByCollection: PopulationsByCollection
result: any result: any

View File

@@ -1,3 +1,5 @@
import type { DocumentEvent, FieldSchemaJSON } from 'payload'
export type LivePreviewArgs = {} export type LivePreviewArgs = {}
export type LivePreview = void export type LivePreview = void
@@ -10,9 +12,10 @@ export type PopulationsByCollection = {
}> }>
} }
// TODO: import this from `payload/admin/components/utilities/DocumentEvents/types.ts` export type LivePreviewMessageEvent<T> = MessageEvent<{
export type UpdatedDocument = { data: T
entitySlug: string externallyUpdatedRelationship?: DocumentEvent
id?: number | string fieldSchemaJSON: FieldSchemaJSON
updatedAt: string locale?: string
} type: 'payload-live-preview'
}>

View File

@@ -507,3 +507,9 @@ export type ClientFieldSchemaMap = Map<
| ClientField | ClientField
| ClientTab | ClientTab
> >
export type DocumentEvent = {
entitySlug: string
id?: number | string
updatedAt: string
}

View File

@@ -1338,6 +1338,7 @@ export {
type CustomVersionParser, type CustomVersionParser,
} from './utilities/dependencies/dependencyChecker.js' } from './utilities/dependencies/dependencyChecker.js'
export { getDependencies } from './utilities/dependencies/getDependencies.js' export { getDependencies } from './utilities/dependencies/getDependencies.js'
export type { FieldSchemaJSON } from './utilities/fieldSchemaToJSON.js'
export { export {
findUp, findUp,
findUpSync, findUpSync,

View File

@@ -1,19 +1,15 @@
'use client' 'use client'
import React, { createContext, useContext, useState } from 'react' import type { DocumentEvent } from 'payload'
export type UpdatedDocument = { import React, { createContext, useContext, useState } from 'react'
entitySlug: string
id?: number | string
updatedAt: string
}
const Context = createContext({ const Context = createContext({
mostRecentUpdate: null, mostRecentUpdate: null,
reportUpdate: (doc: UpdatedDocument) => null, reportUpdate: (doc: DocumentEvent) => null,
}) })
export const DocumentEventsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const DocumentEventsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [mostRecentUpdate, reportUpdate] = useState<UpdatedDocument>(null) const [mostRecentUpdate, reportUpdate] = useState<DocumentEvent>(null)
return <Context.Provider value={{ mostRecentUpdate, reportUpdate }}>{children}</Context.Provider> return <Context.Provider value={{ mostRecentUpdate, reportUpdate }}>{children}</Context.Provider>
} }

View File

@@ -1,6 +1,11 @@
import type { Payload } from 'payload' import type { Payload } from 'payload'
import { handleMessage, mergeData, traverseRichText } from '@payloadcms/live-preview' import {
handleMessage,
type LivePreviewMessageEvent,
mergeData,
traverseRichText,
} from '@payloadcms/live-preview'
import path from 'path' import path from 'path'
import { getFileByPath } from 'payload' import { getFileByPath } from 'payload'
import { fieldSchemaToJSON } from 'payload/shared' import { fieldSchemaToJSON } from 'payload/shared'
@@ -97,7 +102,7 @@ describe('Collections - Live Preview', () => {
type: 'payload-live-preview', type: 'payload-live-preview',
}, },
origin: serverURL, origin: serverURL,
} as MessageEvent, } as MessageEvent as LivePreviewMessageEvent<Page>,
initialData: { initialData: {
title: 'Test Page', title: 'Test Page',
} as Page, } as Page,
@@ -118,7 +123,7 @@ describe('Collections - Live Preview', () => {
type: 'payload-live-preview', type: 'payload-live-preview',
}, },
origin: serverURL, origin: serverURL,
} as MessageEvent, } as MessageEvent as LivePreviewMessageEvent<Page>,
initialData: { initialData: {
title: 'Test Page', title: 'Test Page',
} as Page, } as Page,

View File

@@ -135,9 +135,6 @@ export interface Page {
} | null); } | null);
url?: string | null; url?: string | null;
label: string; label: string;
/**
* Choose how the link should be rendered.
*/
appearance?: ('primary' | 'secondary') | null; appearance?: ('primary' | 'secondary') | null;
}; };
id?: string | null; id?: string | null;
@@ -172,9 +169,6 @@ export interface Page {
} | null); } | null);
url?: string | null; url?: string | null;
label: string; label: string;
/**
* Choose how the link should be rendered.
*/
appearance?: ('default' | 'primary' | 'secondary') | null; appearance?: ('default' | 'primary' | 'secondary') | null;
}; };
id?: string | null; id?: string | null;
@@ -208,18 +202,12 @@ export interface Page {
value: string | Post; value: string | Post;
}[] }[]
| null; | null;
/**
* This field is auto-populated after-read
*/
populatedDocs?: populatedDocs?:
| { | {
relationTo: 'posts'; relationTo: 'posts';
value: string | Post; value: string | Post;
}[] }[]
| null; | null;
/**
* This field is auto-populated after-read
*/
populatedDocsTotal?: number | null; populatedDocsTotal?: number | null;
id?: string | null; id?: string | null;
blockName?: string | null; blockName?: string | null;
@@ -379,9 +367,6 @@ export interface Post {
} | null); } | null);
url?: string | null; url?: string | null;
label: string; label: string;
/**
* Choose how the link should be rendered.
*/
appearance?: ('primary' | 'secondary') | null; appearance?: ('primary' | 'secondary') | null;
}; };
id?: string | null; id?: string | null;
@@ -416,9 +401,6 @@ export interface Post {
} | null); } | null);
url?: string | null; url?: string | null;
label: string; label: string;
/**
* Choose how the link should be rendered.
*/
appearance?: ('default' | 'primary' | 'secondary') | null; appearance?: ('default' | 'primary' | 'secondary') | null;
}; };
id?: string | null; id?: string | null;
@@ -452,18 +434,12 @@ export interface Post {
value: string | Post; value: string | Post;
}[] }[]
| null; | null;
/**
* This field is auto-populated after-read
*/
populatedDocs?: populatedDocs?:
| { | {
relationTo: 'posts'; relationTo: 'posts';
value: string | Post; value: string | Post;
}[] }[]
| null; | null;
/**
* This field is auto-populated after-read
*/
populatedDocsTotal?: number | null; populatedDocsTotal?: number | null;
id?: string | null; id?: string | null;
blockName?: string | null; blockName?: string | null;
@@ -534,9 +510,6 @@ export interface Ssr {
} | null); } | null);
url?: string | null; url?: string | null;
label: string; label: string;
/**
* Choose how the link should be rendered.
*/
appearance?: ('primary' | 'secondary') | null; appearance?: ('primary' | 'secondary') | null;
}; };
id?: string | null; id?: string | null;
@@ -571,9 +544,6 @@ export interface Ssr {
} | null); } | null);
url?: string | null; url?: string | null;
label: string; label: string;
/**
* Choose how the link should be rendered.
*/
appearance?: ('default' | 'primary' | 'secondary') | null; appearance?: ('default' | 'primary' | 'secondary') | null;
}; };
id?: string | null; id?: string | null;
@@ -607,18 +577,12 @@ export interface Ssr {
value: string | Post; value: string | Post;
}[] }[]
| null; | null;
/**
* This field is auto-populated after-read
*/
populatedDocs?: populatedDocs?:
| { | {
relationTo: 'posts'; relationTo: 'posts';
value: string | Post; value: string | Post;
}[] }[]
| null; | null;
/**
* This field is auto-populated after-read
*/
populatedDocsTotal?: number | null; populatedDocsTotal?: number | null;
id?: string | null; id?: string | null;
blockName?: string | null; blockName?: string | null;
@@ -677,9 +641,6 @@ export interface SsrAutosave {
} | null); } | null);
url?: string | null; url?: string | null;
label: string; label: string;
/**
* Choose how the link should be rendered.
*/
appearance?: ('primary' | 'secondary') | null; appearance?: ('primary' | 'secondary') | null;
}; };
id?: string | null; id?: string | null;
@@ -714,9 +675,6 @@ export interface SsrAutosave {
} | null); } | null);
url?: string | null; url?: string | null;
label: string; label: string;
/**
* Choose how the link should be rendered.
*/
appearance?: ('default' | 'primary' | 'secondary') | null; appearance?: ('default' | 'primary' | 'secondary') | null;
}; };
id?: string | null; id?: string | null;
@@ -750,18 +708,12 @@ export interface SsrAutosave {
value: string | Post; value: string | Post;
}[] }[]
| null; | null;
/**
* This field is auto-populated after-read
*/
populatedDocs?: populatedDocs?:
| { | {
relationTo: 'posts'; relationTo: 'posts';
value: string | Post; value: string | Post;
}[] }[]
| null; | null;
/**
* This field is auto-populated after-read
*/
populatedDocsTotal?: number | null; populatedDocsTotal?: number | null;
id?: string | null; id?: string | null;
blockName?: string | null; blockName?: string | null;
@@ -1393,9 +1345,6 @@ export interface Header {
} | null); } | null);
url?: string | null; url?: string | null;
label: string; label: string;
/**
* Choose how the link should be rendered.
*/
appearance?: ('default' | 'primary' | 'secondary') | null; appearance?: ('default' | 'primary' | 'secondary') | null;
}; };
id?: string | null; id?: string | null;
@@ -1426,9 +1375,6 @@ export interface Footer {
} | null); } | null);
url?: string | null; url?: string | null;
label: string; label: string;
/**
* Choose how the link should be rendered.
*/
appearance?: ('default' | 'primary' | 'secondary') | null; appearance?: ('default' | 'primary' | 'secondary') | null;
}; };
id?: string | null; id?: string | null;

View File

@@ -28,7 +28,7 @@
} }
], ],
"paths": { "paths": {
"@payload-config": ["./test/database/config.ts"], "@payload-config": ["./test/live-preview/config.ts"],
"@payloadcms/live-preview": ["./packages/live-preview/src"], "@payloadcms/live-preview": ["./packages/live-preview/src"],
"@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"], "@payloadcms/live-preview-react": ["./packages/live-preview-react/src/index.ts"],
"@payloadcms/live-preview-vue": ["./packages/live-preview-vue/src/index.ts"], "@payloadcms/live-preview-vue": ["./packages/live-preview-vue/src/index.ts"],