enhancement(content-manager): window.strapiPreview API + scoped refresh for media (phase 2)

Completes the media story introduced in phase 1. Editors can now swap between
media kinds (image↔video) and clear media fields and see the preview update
without a full page reload. The change also introduces the public
window.strapiPreview surface so integrators can customize how field changes
are reflected in the preview.

Public API:
- window.strapiPreview.version (numeric, starts at 1, under semver)
- onType(type, handler) for all fields of a given type
- onField(path, handler) for one specific path (takes precedence over onType)
- off(key) to deregister by path or type

Handler resolution order: onField → onType → built-in default → unhandled
signal to the admin → existing full-page refresh. Handlers return false to
pass through to the next handler in the chain.

Built-in media default:
- Same-kind: delegates to the phase-1 in-place attribute patch
- Cross-kind: replaces the DOM element with a fresh <img>/<video>,
  preserving the source-map marker, class, and width/height so the wrapper
  stays locatable and layout is stable
- Populated → empty: clears src/srcset/alt/poster
- Empty → populated: no marker exists, falls through to full-page refresh
  (documented limitation; user stories 4/5 partially honored here)

Handler registries live on window so user-registered handlers survive
preview-script re-injection across admin navigation.

Adds a new internal event STRAPI_FIELD_REPLACE_UNHANDLED (iframe → admin).
The admin listens for it and posts strapiUpdate back to the iframe,
reusing the existing full-page-refresh path.

Tests added: 14 new cases covering the handler chain resolver/runner and
the built-in media default (cross-kind, empty transitions, fall-through).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mark Kaylor
2026-04-24 15:52:57 +02:00
parent fc5f0511d4
commit 76914cbff5
3 changed files with 436 additions and 11 deletions
@@ -36,7 +36,7 @@ import { createYupSchema } from '../../utils/validation';
import { InputPopover } from '../components/InputPopover';
import { PreviewHeader } from '../components/PreviewHeader';
import { useGetPreviewUrlQuery } from '../services/preview';
import { PUBLIC_EVENTS } from '../utils/constants';
import { INTERNAL_EVENTS, PUBLIC_EVENTS } from '../utils/constants';
import { getSendMessage } from '../utils/getSendMessage';
import { previewScript } from '../utils/previewScript';
@@ -158,6 +158,17 @@ const PreviewPage = () => {
const sendMessage = getSendMessage(iframeRef);
sendMessage(PUBLIC_EVENTS.STRAPI_SCRIPT, { script });
}
// A field change couldn't be resolved in place and no scoped-refresh handler was
// registered. Fall back to the existing full-page refresh (strapiUpdate) so the preview
// stays consistent with the admin form.
if (event.data?.type === INTERNAL_EVENTS.STRAPI_FIELD_REPLACE_UNHANDLED) {
iframeRef.current?.contentWindow?.postMessage(
{ type: PUBLIC_EVENTS.STRAPI_UPDATE },
// Safe to use because the iframe origin is pinned via allowedOrigins config
new URL(iframeRef.current.src).origin
);
}
};
window.addEventListener('message', handleMessage);
@@ -2,10 +2,43 @@
declare global {
interface Window {
__strapi_previewCleanup?: () => void;
/** Handler registries preserved across re-injections of the preview script. */
__strapiPreviewRegistries?: {
fieldHandlers: Map<string, StrapiPreviewFieldHandler>;
typeHandlers: Map<string, StrapiPreviewFieldHandler>;
};
STRAPI_HIGHLIGHT_HOVER_COLOR?: string;
STRAPI_HIGHLIGHT_ACTIVE_COLOR?: string;
STRAPI_DISABLE_STEGA_DECODING?: boolean;
/**
* Public Strapi live-preview API exposed inside the preview iframe.
*
* Integrators register handlers to customize how field changes are reflected
* in the preview. Resolution order for every field-change event:
*
* 1. onField(path) — most specific
* 2. onType(type) — for all fields of a type
* 3. built-in default for the type (Strapi ships one for `media`)
* 4. full-page refresh fallback (signaled via an internal message)
*
* Handlers return `false` to pass through to the next handler in the chain.
* Anything else means the change was handled.
*/
strapiPreview?: {
/** Public API version. Integrators can check this to gate on capabilities. */
version: number;
onType(type: string, handler: StrapiPreviewFieldHandler): void;
onField(path: string, handler: StrapiPreviewFieldHandler): void;
/** Deregister a handler by its path (onField key) or type (onType key). */
off(key: string): void;
};
}
type StrapiPreviewFieldHandler = (
value: unknown,
element: Element,
meta: { path: string; type: string }
) => boolean | void;
}
/**
@@ -46,6 +79,12 @@ const previewScript = (config: PreviewScriptConfig) => {
STRAPI_FIELD_CHANGE: 'strapiFieldChange',
STRAPI_FIELD_FOCUS_INTENT: 'strapiFieldFocusIntent',
STRAPI_FIELD_SINGLE_CLICK_HINT: 'strapiFieldSingleClickHint',
/**
* Iframe → admin. Signals that a field change could not be resolved in-place and no
* scoped-refresh handler was registered. The admin responds with the existing strapiUpdate
* full-page refresh so the preview never gets stuck in a stale state.
*/
STRAPI_FIELD_REPLACE_UNHANDLED: 'strapiFieldReplaceUnhandled',
} as const;
/* -----------------------------------------------------------------------------------------------
@@ -164,6 +203,143 @@ const previewScript = (config: PreviewScriptConfig) => {
* a single source of truth for them. It's the only way to do this because this script can't
* refer to any variables outside of its own scope, because it's stringified before it's run.
*/
/* -----------------------------------------------------------------------------------------------
* Handler chain (scoped-refresh primitive)
*
* The dispatch chain is resolution-order independent of any registries — it takes them as an
* argument. The runtime portion of the IIFE wires up the actual `fieldHandlers` / `typeHandlers`
* Maps (preserved on `window` across re-injections) and the built-in type defaults.
* ---------------------------------------------------------------------------------------------*/
type FieldHandler = StrapiPreviewFieldHandler;
/**
* Built-in default for `media` fields. Handles:
* - populated → populated, same-kind: delegates to patchMediaElement
* - populated → populated, cross-kind (image↔video): replaces the DOM element, preserving
* marker + class + width/height so the wrapper stays locatable and layout is stable
* - populated → empty: clears src / srcset / alt / poster on the target
*
* Empty → populated cannot be handled here (no marker element exists in the DOM), and falls
* through to the full-page refresh fallback.
*/
const BUILT_IN_MEDIA_HANDLER: FieldHandler = (value, element) => {
if (!(element instanceof HTMLElement)) return false;
const target = findMediaTarget(element);
if (!target) return false;
const currentTag = target.tagName.toLowerCase();
// Populated → empty: clear attributes rather than removing the element so the marker stays
// in the DOM and future changes can still find a target.
if (value == null) {
if (currentTag === 'img') {
target.removeAttribute('src');
target.removeAttribute('srcset');
target.removeAttribute('alt');
} else if (currentTag === 'video') {
target.removeAttribute('src');
target.removeAttribute('poster');
} else if (currentTag === 'picture') {
target.querySelectorAll('source').forEach((s) => s.removeAttribute('srcset'));
const img = target.querySelector('img');
if (img) {
img.removeAttribute('src');
img.removeAttribute('alt');
}
}
return true;
}
if (typeof value !== 'object') return false;
const mime = typeof (value as MediaValue).mime === 'string' ? (value as MediaValue).mime! : '';
const mimePrefix = getMimePrefix(mime);
const desiredTag: 'img' | 'video' | null =
mimePrefix === 'image' ? 'img' : mimePrefix === 'video' ? 'video' : null;
if (!desiredTag) return false;
const currentKind: 'image' | 'video' | null =
currentTag === 'img' || currentTag === 'picture'
? 'image'
: currentTag === 'video'
? 'video'
: null;
if (currentKind === mimePrefix) {
// Same kind — in-place attribute patch
return patchMediaElement(target, value as MediaValue);
}
// Cross-kind: replace the element with a fresh one of the right tag.
const newEl = document.createElement(desiredTag);
const rawUrl = typeof (value as MediaValue).url === 'string' ? (value as MediaValue).url! : '';
if (!rawUrl) return false;
newEl.setAttribute('src', resolveMediaUrl(rawUrl, target.getAttribute('src')));
if (desiredTag === 'img') {
const alt = (value as MediaValue).alternativeText;
if (typeof alt === 'string') newEl.setAttribute('alt', alt);
} else {
(newEl as HTMLVideoElement).controls = true;
const preview = (value as MediaValue).previewUrl;
if (typeof preview === 'string') {
newEl.setAttribute('poster', resolveMediaUrl(preview, target.getAttribute('poster')));
}
}
// Preserve the marker so the new element keeps participating in live-preview.
const marker = target.getAttribute(SOURCE_ATTRIBUTE);
if (marker) newEl.setAttribute(SOURCE_ATTRIBUTE, marker);
// Preserve class + layout attributes so the swap doesn't break the integrator's styling.
const className = target.getAttribute('class');
if (className) newEl.setAttribute('class', className);
const width = target.getAttribute('width');
if (width) newEl.setAttribute('width', width);
const height = target.getAttribute('height');
if (height) newEl.setAttribute('height', height);
target.replaceWith(newEl);
return true;
};
/**
* Resolve the ordered list of handlers to try for a given (field, type) pair.
* Order: onField (most specific) → onType → built-in type default.
*/
const resolveHandlerChain = (
field: string,
type: string,
registries: {
fieldHandlers: Map<string, FieldHandler>;
typeHandlers: Map<string, FieldHandler>;
builtInTypeHandlers: Map<string, FieldHandler>;
}
): FieldHandler[] => {
return [
registries.fieldHandlers.get(field),
registries.typeHandlers.get(type),
registries.builtInTypeHandlers.get(type),
].filter(Boolean) as FieldHandler[];
};
/**
* Walks the handler chain, stopping at the first one that doesn't return `false`.
* Returns true if any handler claimed the change; false otherwise.
*/
const runHandlerChain = (
handlers: FieldHandler[],
value: unknown,
element: Element,
meta: { path: string; type: string }
): boolean => {
for (let i = 0; i < handlers.length; i += 1) {
const result = handlers[i](value, element, meta);
if (result !== false) return true;
}
return false;
};
if (!shouldRun) {
return {
INTERNAL_EVENTS,
@@ -172,6 +348,9 @@ const previewScript = (config: PreviewScriptConfig) => {
findMediaTarget,
patchMediaElement,
resolveMediaUrl,
BUILT_IN_MEDIA_HANDLER,
resolveHandlerChain,
runHandlerChain,
},
};
}
@@ -191,6 +370,66 @@ const previewScript = (config: PreviewScriptConfig) => {
return document.querySelectorAll(`[${SOURCE_ATTRIBUTE}*="path=${path}"]`);
};
/* -----------------------------------------------------------------------------------------------
* Public API setup (window.strapiPreview)
*
* Registries live on `window` so user-registered handlers survive preview-script re-injection
* (e.g. when the admin navigates between entries).
* ---------------------------------------------------------------------------------------------*/
const registries = window.__strapiPreviewRegistries ?? {
fieldHandlers: new Map<string, FieldHandler>(),
typeHandlers: new Map<string, FieldHandler>(),
};
window.__strapiPreviewRegistries = registries;
// Built-in defaults are always re-bound from code (they come from this script's closure).
const builtInTypeHandlers = new Map<string, FieldHandler>([['media', BUILT_IN_MEDIA_HANDLER]]);
window.strapiPreview = {
version: 1,
onType: (type, handler) => {
registries.typeHandlers.set(type, handler);
},
onField: (path, handler) => {
registries.fieldHandlers.set(path, handler);
},
off: (key) => {
registries.fieldHandlers.delete(key);
registries.typeHandlers.delete(key);
},
};
const signalUnhandled = (field: string, type: string) => {
sendMessage(INTERNAL_EVENTS.STRAPI_FIELD_REPLACE_UNHANDLED, { field, type });
};
const dispatchScopedRefresh = (
field: string,
value: unknown,
type: string,
elements: NodeListOf<Element>
) => {
const handlers = resolveHandlerChain(field, type, {
...registries,
builtInTypeHandlers,
});
if (handlers.length === 0 || elements.length === 0) {
signalUnhandled(field, type);
return;
}
const meta = { path: field, type };
let anyHandled = false;
elements.forEach((element) => {
if (runHandlerChain(handlers, value, element, meta)) {
anyHandled = true;
}
});
if (!anyHandled) {
signalUnhandled(field, type);
}
};
/* -----------------------------------------------------------------------------------------------
* Functionality pieces
* ---------------------------------------------------------------------------------------------*/
@@ -663,16 +902,17 @@ const previewScript = (config: PreviewScriptConfig) => {
const elements = getElementsByPath(field);
// Media fields: patch attributes on the nearest media element in place.
// Shape-changing edits (cross-kind swap, empty transitions) are handled by the full-page
// refresh that runs on save, until the scoped-refresh primitive lands in a later phase.
if (type === 'media') {
if (value == null) return;
elements.forEach((element) => {
if (!(element instanceof HTMLElement)) return;
const target = findMediaTarget(element);
patchMediaElement(target, value as MediaValue);
});
// If any handler (user-registered or built-in) applies to this (field, type), route
// through the scoped-refresh dispatcher. This covers media today; future phases will
// add built-ins for blocks.
const hasHandler =
!!type &&
(registries.fieldHandlers.has(field) ||
registries.typeHandlers.has(type) ||
builtInTypeHandlers.has(type));
if (hasHandler) {
dispatchScopedRefresh(field, value, type!, elements);
highlightManager.updateAllHighlights();
return;
}
@@ -249,4 +249,178 @@ describe('previewScript helpers', () => {
expect(getHelpers().patchMediaElement(null, { url: 'x', mime: 'image/jpeg' })).toBe(false);
});
});
describe('resolveHandlerChain', () => {
const meta = { path: 'hero.image', type: 'media' };
const makeRegistries = () => ({
fieldHandlers: new Map<string, any>(),
typeHandlers: new Map<string, any>(),
builtInTypeHandlers: new Map<string, any>(),
});
it('orders handlers: field → type → built-in', () => {
const r = makeRegistries();
const a = jest.fn();
const b = jest.fn();
const c = jest.fn();
r.fieldHandlers.set('hero.image', a);
r.typeHandlers.set('media', b);
r.builtInTypeHandlers.set('media', c);
const chain = getHelpers().resolveHandlerChain('hero.image', 'media', r);
expect(chain).toEqual([a, b, c]);
});
it('omits handlers that are not registered', () => {
const r = makeRegistries();
const builtIn = jest.fn();
r.builtInTypeHandlers.set('media', builtIn);
const chain = getHelpers().resolveHandlerChain('hero.image', 'media', r);
expect(chain).toEqual([builtIn]);
});
it('returns an empty chain when nothing is registered', () => {
const chain = getHelpers().resolveHandlerChain('x', 'y', makeRegistries());
expect(chain).toEqual([]);
});
});
describe('runHandlerChain', () => {
const meta = { path: 'hero.image', type: 'media' };
const element = document.createElement('img');
it('stops at the first handler that does not return false', () => {
const first = jest.fn(() => true);
const second = jest.fn();
const handled = getHelpers().runHandlerChain([first, second], {}, element, meta);
expect(handled).toBe(true);
expect(first).toHaveBeenCalledTimes(1);
expect(second).not.toHaveBeenCalled();
});
it('treats undefined return as handled (same as true)', () => {
const h = jest.fn(() => undefined);
const handled = getHelpers().runHandlerChain([h], {}, element, meta);
expect(handled).toBe(true);
});
it('falls through to the next handler when one returns false', () => {
const first = jest.fn(() => false);
const second = jest.fn(() => true);
const handled = getHelpers().runHandlerChain([first, second], {}, element, meta);
expect(handled).toBe(true);
expect(first).toHaveBeenCalled();
expect(second).toHaveBeenCalled();
});
it('returns false when every handler returns false', () => {
const h = jest.fn(() => false);
const handled = getHelpers().runHandlerChain([h, h, h], {}, element, meta);
expect(handled).toBe(false);
});
it('returns false for an empty chain', () => {
const handled = getHelpers().runHandlerChain([], {}, element, meta);
expect(handled).toBe(false);
});
});
describe('BUILT_IN_MEDIA_HANDLER', () => {
const meta = { path: 'hero.image', type: 'media' };
it('delegates to the in-place patch when old and new are the same kind', () => {
const img = document.createElement('img');
img.setAttribute('src', 'https://example.com/old.jpg');
const handled = getHelpers().BUILT_IN_MEDIA_HANDLER(
{ url: 'https://example.com/new.jpg', mime: 'image/jpeg' },
img,
meta
);
expect(handled).toBe(true);
expect(img).toHaveAttribute('src', 'https://example.com/new.jpg');
});
it('swaps <img> to <video> on image → video cross-kind change, preserving marker', () => {
const wrapper = document.createElement('div');
const img = document.createElement('img');
img.setAttribute('src', 'https://example.com/old.jpg');
img.setAttribute('data-strapi-source', 'path=hero.image&type=media');
img.setAttribute('class', 'hero-class');
img.setAttribute('width', '640');
wrapper.appendChild(img);
const handled = getHelpers().BUILT_IN_MEDIA_HANDLER(
{ url: 'https://example.com/clip.mp4', mime: 'video/mp4' },
wrapper,
meta
);
expect(handled).toBe(true);
const video = wrapper.querySelector('video');
expect(video).not.toBeNull();
expect(video!).toHaveAttribute('src', 'https://example.com/clip.mp4');
expect(video!).toHaveAttribute('data-strapi-source', 'path=hero.image&type=media');
expect(video!).toHaveAttribute('class', 'hero-class');
expect(video!).toHaveAttribute('width', '640');
expect(wrapper.querySelector('img')).toBeNull();
});
it('swaps <video> to <img> on video → image cross-kind change', () => {
const wrapper = document.createElement('div');
const video = document.createElement('video');
video.setAttribute('src', 'https://example.com/old.mp4');
video.setAttribute('data-strapi-source', 'path=hero.media&type=media');
wrapper.appendChild(video);
const handled = getHelpers().BUILT_IN_MEDIA_HANDLER(
{ url: 'https://example.com/photo.jpg', mime: 'image/jpeg', alternativeText: 'A photo' },
wrapper,
meta
);
expect(handled).toBe(true);
const img = wrapper.querySelector('img');
expect(img).not.toBeNull();
expect(img!).toHaveAttribute('src', 'https://example.com/photo.jpg');
expect(img!).toHaveAttribute('alt', 'A photo');
expect(wrapper.querySelector('video')).toBeNull();
});
it('clears attributes on populated → empty', () => {
const img = document.createElement('img');
img.setAttribute('src', 'https://example.com/old.jpg');
img.setAttribute('alt', 'old');
img.setAttribute('srcset', 'https://example.com/old.jpg 1x');
const handled = getHelpers().BUILT_IN_MEDIA_HANDLER(null, img, meta);
expect(handled).toBe(true);
expect(img).not.toHaveAttribute('src');
expect(img).not.toHaveAttribute('srcset');
expect(img).not.toHaveAttribute('alt');
});
it('returns false when no media target is in the subtree', () => {
const wrapper = document.createElement('div');
wrapper.appendChild(document.createElement('span'));
const handled = getHelpers().BUILT_IN_MEDIA_HANDLER(
{ url: 'https://example.com/new.jpg', mime: 'image/jpeg' },
wrapper,
meta
);
expect(handled).toBe(false);
});
it('returns false for unknown mime types (fall through to unhandled)', () => {
const img = document.createElement('img');
img.setAttribute('src', 'https://example.com/old.jpg');
const handled = getHelpers().BUILT_IN_MEDIA_HANDLER(
{ url: 'https://example.com/file.pdf', mime: 'application/pdf' },
img,
meta
);
expect(handled).toBe(false);
});
});
});