diff --git a/craft/src/editor/RenderNode.responsive.test.tsx b/craft/src/editor/RenderNode.responsive.test.tsx index 07951ac..fc2b70d 100644 --- a/craft/src/editor/RenderNode.responsive.test.tsx +++ b/craft/src/editor/RenderNode.responsive.test.tsx @@ -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'); + }); +}); diff --git a/craft/src/editor/RenderNode.tsx b/craft/src/editor/RenderNode.tsx index bdfd6a5..8789b58 100644 --- a/craft/src/editor/RenderNode.tsx +++ b/craft/src/editor/RenderNode.tsx @@ -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]); }; diff --git a/craft/src/utils/responsive-style.test.ts b/craft/src/utils/responsive-style.test.ts index 8b1b3be..6b6d636 100644 --- a/craft/src/utils/responsive-style.test.ts +++ b/craft/src/utils/responsive-style.test.ts @@ -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(); + }); +}); diff --git a/craft/src/utils/responsive-style.ts b/craft/src/utils/responsive-style.ts index af04bc5..075f121 100644 --- a/craft/src/utils/responsive-style.ts +++ b/craft/src/utils/responsive-style.ts @@ -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(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 { if (device === 'desktop') return {};