Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/Fable.Cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* [TS] Expose optional `stack` property on `Exception` (by @MangelMaxime)
* [Python] Fix `nonlocal`/`global` declarations generated inside `match/case` bodies causing `SyntaxError` (by @dbrattli)
* [Python] Fix exception variable captured in deferred closures causing `NameError` (PEP 3110 scoping) (by @dbrattli)
* [JS/TS] Support format specifiers and single hole in JSX string templates (by @MangelMaxime)

## 5.0.0-rc.2 - 2026-03-03

Expand Down
1 change: 1 addition & 0 deletions src/Fable.Compiler/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* [TS] Correctly resolve type references for `TypeScriptTaggedUnion` (by @MangelMaxime and @jrwone0)
* [Python] Fix `nonlocal`/`global` declarations generated inside `match/case` bodies causing `SyntaxError` (by @dbrattli)
* [Python] Fix exception variable captured in deferred closures causing `NameError` (PEP 3110 scoping) (by @dbrattli)
* [JS/TS] Support format specifiers and single hole in JSX string templates (by @MangelMaxime)

## 5.0.0-rc.2 - 2026-03-03

Expand Down
29 changes: 29 additions & 0 deletions src/Fable.Transforms/Fable2Babel.fs
Original file line number Diff line number Diff line change
Expand Up @@ -2263,6 +2263,35 @@ but thanks to the optimisation done below we get
| head :: parts -> (stripImports com ctx range head) :: parts
| parts -> parts

let values = values |> List.mapToArray (transformAsExpr com ctx)
Expression.jsxTemplate (List.toArray parts, values) |> Some
| MaybeCasted(Fable.Call(Fable.Import({
Selector = "concat"
Path = Naming.EndsWith "/String.js" _
Kind = Fable.LibraryImport _
},
_,
_),
callInfo,
_,
_)) :: _ ->
// The F# compiler lowers a simple interpolated string (no format specifiers)
// to String.Concat(part1, value, part2, ...). Convert back to a StringTemplate.
// StringTemplate requires parts.Length = values.Length + 1, strictly alternating,
// starting and ending with a string part.
let rec buildParts currentPart parts values args =
match args with
| [] -> List.rev (currentPart :: parts), List.rev values
| Fable.Value(Fable.StringConstant s, _) :: rest -> buildParts (currentPart + s) parts values rest
| expr :: rest -> buildParts "" (currentPart :: parts) (expr :: values) rest

let parts, values = buildParts "" [] [] callInfo.Args

let parts =
match parts with
| head :: tail -> (stripImports com ctx range head) :: tail
| parts -> parts

let values = values |> List.mapToArray (transformAsExpr com ctx)
Expression.jsxTemplate (List.toArray parts, values) |> Some
| _ ->
Expand Down
82 changes: 65 additions & 17 deletions src/Fable.Transforms/Replacements.Util.fs
Original file line number Diff line number Diff line change
Expand Up @@ -456,40 +456,88 @@ let makeStringTemplate

StringTemplate(tag, parts, values)

let makeStringTemplateFrom simpleFormats values =

// Parses an F# interpolated string format, finds every %P() hole (optionally preceded by a
// printf format specifier), and builds a StringTemplate from the literal parts and the
// supplied value expressions.
//
// For each hole the caller supplies `handleFormatSpec`:
// - None → the hole has no preceding format spec (or only a simple one from simpleFormats);
// the value is used as-is.
// - Some fmtSpec → the hole has a non-simple format spec; the callback receives the spec
// (e.g. "%0.2f") and the value expression. Returning Some wraps the value;
// returning None aborts the whole template (None is returned for the string).
let private makeStringTemplateFromWith
(simpleFormats: string array)
(handleFormatSpec: string -> Expr -> Expr option)
(values: Expr list)
=
function
| StringConst str ->
// In the case of interpolated strings, the F# compiler doesn't resolve escaped %
// (though it does resolve double braces {{ }})
let str = str.Replace("%%", "%")

(Some [],
Regex.Matches(str, @"((?<!%)%(?:[0+\- ]*)(?:\d+)?(?:\.\d+)?\w)?%P\(\)")
|> Seq.cast<Match>)
let matches =
Regex.Matches(str, @"((?<!%)%(?:[0+\- ]*)(?:\d+)?(?:\.\d+)?\w)?%P\(\)")
|> Seq.cast<Match>

(Some([], values), matches)
||> Seq.fold (fun acc m ->
match acc with
| None -> None
| Some acc ->
// TODO: If arguments need format, format them individually
let doesNotNeedFormat =
not m.Groups[1].Success || (Array.contains m.Groups[1].Value simpleFormats)
| Some(_, []) -> None // fewer values than matches
| Some(mapped, value :: restValues) ->
let needsFormat =
m.Groups[1].Success && not (Array.contains m.Groups[1].Value simpleFormats)

let resolved =
if needsFormat then
handleFormatSpec m.Groups[1].Value value
else
Some value

if doesNotNeedFormat then
resolved |> Option.map (fun v -> v :: mapped, restValues)
)
|> Option.map (fun (mapped, _) ->
let holes =
matches
|> Seq.map (fun m ->
{|
Index = m.Index
Length = m.Length
|}
:: acc
|> Some
else
None
)
|> Option.map (fun holes ->
let holes = List.toArray holes |> Array.rev
makeStringTemplate None str holes values
)
|> Seq.toArray

let mappedValues = List.rev mapped
makeStringTemplate None str holes mappedValues
)
| _ -> None

/// Try to build a StringTemplate from an F# interpolated string.
/// Returns None if any hole carries a format specifier that is not in simpleFormats,
/// because those require runtime formatting that can't be inlined into a plain template.
let makeStringTemplateFrom simpleFormats values strExpr =
// Abort (return None) for any non-simple format spec.
makeStringTemplateFromWith simpleFormats (fun _ _ -> None) values strExpr

/// Like makeStringTemplateFrom but always succeeds for string literals.
/// Values with format specifiers not in simpleFormats are individually wrapped in a
/// String.interpolate call so they evaluate to strings.
/// Used for JSX templates, where every hole must produce a value (formatted or not).
let makeStringTemplateFromAllowingFormat (com: ICompiler) simpleFormats (values: Expr list) strExpr =
makeStringTemplateFromWith
simpleFormats
(fun fmtSpec value ->
// Wrap value in: String.interpolate("%fmtspec%P()", [| value |])
let fmtStr = Value(StringConstant(fmtSpec + "%P()"), None)
let valArr = Value(NewArray(ArrayValues [ value ], Any, MutableArray), None)
Helper.LibCall(com, "String", "interpolate", String, [ fmtStr; valArr ]) |> Some
)
values
strExpr

let rec namesof com ctx acc e =
match acc, e with
| acc, Get(e, ExprGet(StringConst prop), _, _) -> namesof com ctx (prop :: acc) e
Expand Down
10 changes: 8 additions & 2 deletions src/Fable.Transforms/Replacements.fs
Original file line number Diff line number Diff line change
Expand Up @@ -1253,8 +1253,14 @@ let fsFormat (com: ICompiler) (ctx: Context) r t (i: CallInfo) (thisArg: Expr op
match makeStringTemplateFrom [| "%s"; "%i" |] templateArgs str with
| Some v -> makeValue r v |> Some
| None ->
Helper.LibCall(com, "String", "interpolate", t, [ str; values ], i.SignatureArgTypes, ?loc = r)
|> Some
// Try to build a StringTemplate where formatted values are individually wrapped
// in String.interpolate calls. This keeps the AST as a StringTemplate (rather than
// a single runtime interpolate call) so that JSX templates can still use it.
match makeStringTemplateFromAllowingFormat com [| "%s"; "%i" |] templateArgs str with
| Some v -> makeValue r v |> Some
| None ->
Helper.LibCall(com, "String", "interpolate", t, [ str; values ], i.SignatureArgTypes, ?loc = r)
|> Some
| ".ctor", _, arg :: _ ->
Helper.LibCall(com, "String", "printf", t, [ arg ], i.SignatureArgTypes, ?loc = r)
|> Some
Expand Down
25 changes: 25 additions & 0 deletions tests/React/Counter.fs
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,28 @@ let CounterJSX(init: int) =
</button>
</div>
"""

// Regression test for https://github.com/fable-compiler/Fable/issues/3999
// Single string hole: F# compiler lowers to String.Concat rather than printf
[<JSX.Component>]
let SingleHoleJSX(text: string) =
JSX.html
$"""
<div data-testid="single-hole">{text}</div>
"""

// Multiple string holes with no format specifier
[<JSX.Component>]
let MultiHoleJSX(first: string) (last: string) =
JSX.html
$"""
<div data-testid="multi-hole">{first} {last}</div>
"""

// Hole with a format specifier (the original issue #3999)
[<JSX.Component>]
let FormatSpecifierJSX(value: float) =
JSX.html
$"""
<div data-testid="format-specifier">%.3f{value}</div>
"""
24 changes: 18 additions & 6 deletions tests/React/__tests__/React.fs
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,23 @@ Jest.describe("React tests", (fun () ->
Jest.expect(cell).toHaveTextContent("7")
))

// See #2628
Jest.test("Curried functions passed to plugin transforms", (fun () ->
let fn a b = a + b
let elem = RTL.render(ComponentAcceptingCurriedFunction fn "ignored")
let text = elem.getByTestId "text"
Jest.expect(text).toHaveTextContent("3")
// See https://github.com/fable-compiler/Fable/issues/3999
Jest.test("JSX single string hole renders correctly", (fun () ->
let elem = RTL.render(Counter.SingleHoleJSX("hello") |> unbox)
let node = elem.getByTestId "single-hole"
Jest.expect(node).toHaveTextContent("hello")
))

Jest.test("JSX multiple string holes render correctly", (fun () ->
let elem = RTL.render(Counter.MultiHoleJSX("John") ("Doe") |> unbox)
let node = elem.getByTestId "multi-hole"
Jest.expect(node).toHaveTextContent("John Doe")
))

Jest.test("JSX format specifier hole renders correctly", (fun () ->
let elem = RTL.render(Counter.FormatSpecifierJSX(3.14159) |> unbox)
let node = elem.getByTestId "format-specifier"
Jest.expect(node).toHaveTextContent("3.142")
))

))
Loading