Pin fixed-width images so they survive the design-JSON round-trip#33
Merged
Conversation
A fixed image width (px or number) was stored in the natural-size field with autoWidth:true, so re-opening the exported design in an editor reloaded the image's intrinsic dimensions and the explicit width was lost (the image snapped back to its original size on selection). Treat a fixed px/number width as display intent: emit autoWidth:false with maxWidth as a percent of the column's content slot — the canonical fixed-size shape, kept independent of the natural src.width/height. The percent is computed from the same available-width geometry the renderers use (contentWidth x column share, minus paddings/borders) by a width-aware pass in both renderToHtml (via Column's threaded context) and renderToJson (via the tree walk). A percent width/maxWidth already pinned and is unchanged; a no-width image stays responsive (autoWidth:true). - add utils/image-sizing.ts (slot geometry + px->percent conversion) - Image propMapper: capture width/maxWidth as display intent, no longer polluting the natural src.width field - new Image.width-roundtrip tests; update stale assertions that encoded the old natural-size behavior Verified the emitted percent matches the renderer's own available-width math and that the pin no longer jumps when intrinsic dimensions refresh. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes a design-JSON round-trip issue where fixed-width images could lose their intended display size when reloaded in an editor, by encoding fixed widths in Unlayer’s canonical autoWidth:false + percent maxWidth form computed from the real column content slot geometry.
Changes:
- Adds shared slot-geometry utilities and a px/number → percent conversion for pinned image widths.
- Threads layout context through
renderToJsonand applies the pin conversion during JSON emission (and during HTML export via the item render pipeline). - Updates/introduces tests to lock in the fixed-width round-trip behavior and prevent regression.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| packages/react/src/utils/render-to-json.ts | Threads layout context through the JSON tree-walk and converts pinned image widths to canonical percent maxWidth. |
| packages/react/src/utils/image-sizing.ts | New utilities for computing content-slot width and converting px pins to percent maxWidth. |
| packages/react/src/utils/create-component.tsx | Applies the same pin conversion during export when layout geometry is available. |
| packages/react/src/dx-behaviors.test.tsx | Updates DX behavior assertions to reflect new pinned-width semantics. |
| packages/react/src/components/Image.width-roundtrip.test.tsx | Adds regression tests covering JSON round-trip + HTML rendering for pinned widths. |
| packages/react/src/components/Image.tsx | Adjusts prop mapping so width/maxWidth represent display intent (not natural dimensions) and avoids polluting src.width. |
| packages/react/src/components/Image.test.tsx | Updates expectations for pinned widths now emitted as percent maxWidth. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
The bundle was already at 58.4KB on main (97% of the 60KB budget set when it was ~49KB). The image width-pinning fix adds ~4.6KB of dependency-free local geometry, so the budget no longer fits legitimate growth. Raise to 68KB; it still flags accidental dependency bundling (any real dep is 10KB+). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
fixedContentWidth only accepted numbers and px strings, but the renderer (Row's toContentWidthPx parseInts any string) and the exporter's body-width math treat a bare numeric string like "600" as 600px. The slot geometry fell back to 500, producing a wrong pinned-image percent for that input. Accept a numeric string with an optional px unit; still reject "%"/"auto". Caught in review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
toPx() parseFloat'd any string, so a non-px maxWidth on a pinned src (e.g.
the escape hatch {autoWidth:false, maxWidth:'1.5em'}) was misread as px and
converted into a bogus percent. Accept only a number or numeric/px string;
leave other CSS units untouched. Add a test guarding it.
Also correct fixedContentWidth's doc: it mirrors the exporter's body-width
math (bare numeric string = px, %/auto -> fallback), not Row's parseInt
(which would misread '50%' as 50). Both caught in review.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…count - contentWidth parsing: Row's grid CSS and the image slot geometry had separate parsers that disagreed on non-px values (Row's parseInt read "50%" as 50px; the slot math fell back to 500). Extract one shared bodyContentWidthPx and use it in both, so a non-px contentWidth collapses to the same base everywhere. No change for px/number widths; also fixes a latent email-grid bug for % content widths. - renderToJson default cells: counted all children, so a stray non-Column child inflated the cells array beyond the column list and distorted the column-share math (wrong pinned-image percent) and the row layout. Count only <Column> children, matching the existing comment. Both caught in review. +4 tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The slot geometry must match the renderer's available-width math, and the editor's explodePaddingsOrMargins / explodeBorder both parseFloat each token (so '10%' is read as 10). edges() already did this; switch borderEdges() back from strict toPx to parseFloat so the two are consistent and both mirror the renderer. Strict px parsing (toPx) stays only for the display-pin value in pinImageSrc, never for the geometry. Documented the rationale inline. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A fixed image width (px or number) was stored in the natural-size field with autoWidth:true, so re-opening the exported design in an editor reloaded the image's intrinsic dimensions and the explicit width was lost (the image snapped back to its original size on selection).
Treat a fixed px/number width as display intent: emit autoWidth:false with maxWidth as a percent of the column's content slot — the canonical fixed-size shape, kept independent of the natural src.width/height. The percent is computed from the same available-width geometry the renderers use (contentWidth x column share, minus paddings/borders) by a width-aware pass in both renderToHtml (via Column's threaded context) and renderToJson (via the tree walk). A percent width/maxWidth already pinned and is unchanged; a no-width image stays responsive (autoWidth:true).
Verified the emitted percent matches the renderer's own available-width math and that the pin no longer jumps when intrinsic dimensions refresh.
Summary
What does this PR do? Keep it brief.
Changes
Test Plan
pnpm buildpassespnpm testpassesNotes
Any additional context for reviewers.
📖 Storybook Preview: https://unlayer.github.io/elements/pr/33/