fix(site-builder): editor canvas drops blank style values like the exporter
React assigns '' for a blank style value and a blank longhand after a shorthand
({padding:'20px', paddingTop:''}) wipes the shorthand in the canvas; the exporter
drops blanks. ResolvedRender is the one place node style reaches React, so
normalise there.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
64fc21866c
commit
7a1ab36e63
4 files changed
+65
-3
No files matched your search
@@ -128,3 +128,36 @@ describe('RenderNode per-device style resolution (real editor)', () => {
|
||||
expect(h2().hasAttribute('data-wsb-overridden')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RenderNode drops blank style values like the exporter (canvas/published parity)', () => {
|
||||
test('{padding, paddingTop:""} keeps the shorthand (React would wipe it)', () => {
|
||||
mount(
|
||||
JSON.stringify({
|
||||
ROOT: node('Container', { tag: 'div', style: {} }, ['sec1']),
|
||||
sec1: node('Container', { tag: 'section', style: { padding: '20px', paddingTop: '', paddingLeft: undefined, margin: null } }, [], { parent: 'ROOT' }),
|
||||
}),
|
||||
'desktop',
|
||||
);
|
||||
const sec = h!.container.querySelector('section') as HTMLElement;
|
||||
expect(sec.style.padding).toBe('20px');
|
||||
expect(sec.style.paddingTop).toBe('20px');
|
||||
});
|
||||
|
||||
test('a real longhand after the shorthand still wins', () => {
|
||||
mount(
|
||||
JSON.stringify({
|
||||
ROOT: node('Container', { tag: 'div', style: {} }, ['sec1']),
|
||||
sec1: node('Container', { tag: 'section', style: { padding: '20px', paddingTop: '5px' } }, [], { parent: 'ROOT' }),
|
||||
}),
|
||||
'desktop',
|
||||
);
|
||||
const sec = h!.container.querySelector('section') as HTMLElement;
|
||||
expect(sec.style.paddingTop).toBe('5px');
|
||||
expect(sec.style.paddingBottom).toBe('20px');
|
||||
});
|
||||
|
||||
test('also holds on a non-desktop device with overrides', () => {
|
||||
mount(state({ style: { margin: '8px', marginTop: '' }, styleMobile: { textAlign: 'center' } }), 'mobile');
|
||||
expect(h2().style.marginBottom).toBe('8px');
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import React, { useCallback, useContext, useEffect, useMemo, useRef } from 'reac
|
||||
import { createPortal } from 'react-dom';
|
||||
import { NodeElement, useEditor, useNode } from '@craftjs/core';
|
||||
import { useDevice } from '../state/DeviceContext';
|
||||
import { baseStacksOnMobile, hasAnyOverride, hasActiveOverride, resolveStyleForDevice, type ResponsiveProps } from '../utils/responsive-style';
|
||||
import { dropBlankStyle, baseStacksOnMobile, hasAnyOverride, hasActiveOverride, resolveStyleForDevice, type ResponsiveProps } from '../utils/responsive-style';
|
||||
import type { DeviceMode } from '../types';
|
||||
import { GlobalInstanceContext } from '../state/GlobalInstanceContext';
|
||||
|
||||
@@ -87,8 +87,12 @@ const ResolvedRender: React.FC<{ device: DeviceMode }> = ({ device }) => {
|
||||
) : (
|
||||
props.children
|
||||
);
|
||||
const resolved =
|
||||
styleDevice !== 'desktop' ? { ...props, style: resolveStyleForDevice(props as ResponsiveProps, styleDevice) } : props;
|
||||
// The ONE place node style reaches React: drop blank values like the exporter does
|
||||
// (see dropBlankStyle) so the canvas can't wipe a shorthand with a blank longhand.
|
||||
const style = dropBlankStyle(
|
||||
styleDevice !== 'desktop' ? resolveStyleForDevice(props as ResponsiveProps, styleDevice) : (props as ResponsiveProps).style,
|
||||
);
|
||||
const resolved = styleDevice !== 'desktop' || style !== (props as ResponsiveProps).style ? { ...props, style } : props;
|
||||
return React.createElement(type as React.ElementType, resolved, children);
|
||||
}, [type, props, childIds, hydrationTimestamp, styleDevice]);
|
||||
};
|
||||
|
||||
@@ -202,3 +202,15 @@ describe('responsiveCssForNode', () => {
|
||||
expect(responsiveCssForNode('"]', { styleMobile: { fontSize: '2px' } })).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
import { dropBlankStyle as _dropBlankStyle } from './responsive-style';
|
||||
describe('dropBlankStyle (canvas == exporter: blanks are not declarations)', () => {
|
||||
it('drops "", null, undefined; keeps 0 and real values', () => {
|
||||
expect(_dropBlankStyle({ padding: '20px', paddingTop: '', a: null, b: undefined, opacity: 0, zIndex: '0' } as any)).toEqual({ padding: '20px', opacity: 0, zIndex: '0' });
|
||||
});
|
||||
it('returns the same object when nothing is blank, and passes non-objects through', () => {
|
||||
const s = { color: 'red' };
|
||||
expect(_dropBlankStyle(s)).toBe(s);
|
||||
expect(_dropBlankStyle(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -70,6 +70,19 @@ export function resolveStyleForDevice(props: ResponsiveProps, device: DeviceMode
|
||||
return { ...base, ...tablet, ...setEntries(props.styleMobile) } as CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* The style as the CANVAS must render it: blank ('' / null / undefined) entries
|
||||
* dropped, exactly like the exporter (`cssPropsToString`). React assigns '' for a
|
||||
* blank value, and a blank longhand after a shorthand ({padding:'20px',
|
||||
* paddingTop:''}) wipes the shorthand in the browser -- published output keeps it.
|
||||
* Returns the SAME object when nothing is blank (no needless re-render).
|
||||
*/
|
||||
export function dropBlankStyle<T>(style: T): T {
|
||||
if (!style || typeof style !== 'object' || Array.isArray(style)) return style;
|
||||
const rec = style as unknown as StyleRecord;
|
||||
return Object.values(rec).every(isSet) ? style : (setEntries(rec) as unknown as T);
|
||||
}
|
||||
|
||||
/** The raw partial stored for `device` (`{}` for desktop or when absent). */
|
||||
export function overrideFor(props: ResponsiveProps, device: DeviceMode): Partial<CSSProperties> {
|
||||
if (device === 'desktop') return {};
|
||||
|
||||
Reference in new issue
Block a user