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:
shadowdaoandClaude Sonnet 5.5 committed 2026-11-01 09:12:35 -08:00
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');
});
});
+7 -3
View File
@@ -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]);
};
+12
View File
@@ -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();
});
});
+13
View File
@@ -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 {};