Skip to content

Merge styles= into optional className and style correctly - #402

Merged
davesnx merged 2 commits into
mainfrom
styles-optional-merge
Sep 23, 2026
Merged

davesnx merged 2 commits into
mainfrom
styles-optional-merge

Conversation

@davesnx

@davesnx davesnx commented Sep 23, 2026

Copy link
Copy Markdown
Member

Styles_attribute.expand_attributes merges styles= into an element's existing className/style, but it only handled the case where the incoming value is optional. When the element already carried ?className or ?style, the merge concatenated the string option as a string and passed a plain value to the optional argument:

<div ?className styles=x />
/* expanded to */
div ?className:(CSS.className x ^ " " ^ className) ~style:(CSS.styles x) ...

which does not type-check. ?style styles=x did the same through ReactDOM.Style.combine. The branch that did work also used a bare x binder, so <div className=x styles=?y /> expanded to Some x -> x ^ " " ^ x and captured the user's value.

merge_className and merge_style are now one merge over the four optional/required combinations:

  • required + required: unchanged, incoming ^ " " ^ existing (or Style.combine existing incoming).
  • required existing, optional incoming: bind the existing value, match the incoming option (the old behavior, with reserved binders).
  • optional existing, required incoming: the result is always present, so the attribute drops its ? and matches the existing option.
  • both optional: match the pair and stay optional.

Generated binders are __incoming/__existing, following the reserved-name convention the Writer uses.

styled-ppx consumes this library for its own styles= handling (packages/ppx/src/dune lists server-reason-react.styles-attribute), so it picks the fix up with the next server-reason-react release; nothing to change there. #401 builds on the same helper for part and should be rebased onto this.

Tests

At 8513c32, OCaml 5.4.0, quickjs 0.5.1, melange 7.0.1, ocamlformat 0.28.1:

  • dune build @packages/styles-attribute/runtest: 10 tests; four new ones cover the optional-existing, optional-incoming, and both-optional shapes for className and style.
  • dune build @packages/server-reason-react-ppx/runtest: 69 tests; styles.t gains <div ?className styles=x />, <div ?style styles=x /> and <div ?className styles=?x /> in native and Melange mode, snapshot promoted and reviewed.
  • dune build @runtest: all suites pass except packages/router/test/browser, which needs npm install in the checkout.
  • ocamlformat --check on the touched .ml files: clean.

Risk: two-way door. Reverting restores the old merge for the required+required and required+optional shapes, which were the only ones that compiled. Output for those shapes changes only in binder names and an added let.

The expansion only handled an optional incoming value. An existing ?className
or ?style was concatenated as a plain string or style and handed back to the
optional argument, so <div ?className styles=x /> failed to type-check. One
merge now covers the four optional/required combinations; when either side
is always present the attribute drops its ?. Binders are __incoming and
__existing so a user value named x is not captured.
@vercel

vercel Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
server-reason-react Ready Ready Preview Sep 23, 2026 6:22pm UTC

Request Review

@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Benchmarks

No baseline from main available yet, showing absolute values only.

Benchmark ops/sec
trivial/renderToStaticMarkup 2,192,985
trivial/renderToString 1,904,770
depth/10 59,503
depth/25 23,920
depth/50 12,245
depth/100 6,161
width/10 36,512
width/100 3,809
width/500 695.11
width/1000 349.55
table/10 24,481
table/50 5,253
table/100 2,260
table/500 494
props/small 9,822
props/medium 3,396
props/large 1,134
realworld/ecommerce24 6,077
realworld/ecommerce48 3,256
realworld/dashboard 25,709
realworld/blog50 3,971
realworld/form 13,335
primitive/React.string 15,972,216
primitive/React.int 8,050,488
primitive/React.null 24,036,126
primitive/createElement_empty 10,290,245
primitive/createElement_children 849,187
primitive/React.array_10 778,756
primitive/React.array_100 87,079
primitive/React.list_10 851,134
primitive/React.list_100 95,424
rsc/trivial 296,480
rsc/depth/50 4,598
rsc/width/100 496.06
rsc/width/500 88.07
rsc/width/1000 41.61
rsc/table/100 265.57
rsc/table/500 46.39
streaming/renderToStream/wide100 3,411
streaming/renderToStream/suspense-drained 35,328
rsc/render_html/wide100 498.38
rsc/render_html/suspense 14,370
rsc/render_model/wide100 1,033
router/match/static-small-first 18,813,131
router/match/static-small-last 28,069,711
router/match/static-large-first 27,576,726
router/match/static-large-last 26,626,426
router/match/static-large-miss 2,566,305
router/match/dynamic-large-first 1,761,113
router/match/dynamic-large-last 1,951,671
router/endpoint/static-large-last 15,058,921
router/endpoint/dynamic-large-last 1,809,671
router/parse/path-plain 1,902,409
router/parse/path-encoded 1,238,823
router/parse/search 2,374,874

@davesnx
davesnx merged commit 82c52e8 into main Sep 23, 2026
4 of 5 checks passed
@davesnx
davesnx deleted the styles-optional-merge branch September 23, 2026 18:17

This branch was successfully deployed

1 active deployment
Preview — 341e7f41 Deployed Sep 23, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant