-
Couldn't load subscription status.
- Fork 87
Description
Using Feliz with React 19 return a lot of missing key warnings. I investigated this issue and showcase the problem, as well as some solutions
The Issue
So i took quite some time to get to the bottom of this issue and the culprit is Interop.reactApi.Children.toArray using the react Children api.
The Children.toArray function calls the following, which showcases why we now get these warnings. It still recreates the react element with automatic keys .0, .1..., but we get the warning.
`toArray` subfunction
if (__DEV__) {
// If `child` was an element without a `key`, we need to validate if
// it should have had a `key`, before assigning one to `mappedChild`.
// $FlowFixMe[incompatible-type] Flow incorrectly thinks React.Portal doesn't have a key
if (
nameSoFar !== '' &&
child != null &&
isValidElement(child) &&
child.key == null
) {
// We check truthiness of `child._store.validated` instead of being
// inequal to `1` to provide a bit of backward compatibility for any
// libraries (like `fbt`) which may be hacking this property.
if (child._store && !child._store.validated) {
// Mark this child as having failed validation, but let the actual
// renderer print the warning later.
newChild._store.validated = 2;
}
}Solution 1: Spreading children
Looking at the documentation for React.createElement, we can find the following parts:
createElement(type, props, ...children)optional
...children: Zero or more child nodes. They can be any React nodes, including React elements, strings, numbers, portals, empty nodes (null, undefined, true, and false), and arrays of React nodes.
You should only pass children as multiple arguments to createElement if they are all statically known, like createElement('h1', {}, child1, child2, child3). If your children are dynamic, pass the entire array as the third argument: createElement('ul', {}, listItems). This ensures that React will warn you about missing keys for any dynamic lists. For static lists this is not necessary because they never reorder
Currently Feliz handles children by passing them always as props. Before React 19 this was sufficient using the Children.toArray method. We can adjust the createElement function in Feliz to spread children as third optional parameter:
[<Erase>]
type React =
[<ImportMemberAttribute("react")>]
static member inline createElement (
_type: string,
props: obj,
[<ParamArray>] children: ResizeArray<ReactChild>) : ReactElement = jsNativeBy doing this we tell react that everything is handled statically and no missing-key-warning gets emitted.
The functions reactElementWithChild, reactElementWithChildren, createElement can be rewritten accordingly:
`reactElementWithChild`, `reactElementWithChildren`, `createElement`
module ReactHelper =
let inline reactElementWithChild (name: string) (child: 'a) =
React.createElement(
name,
createObj [ ],
ResizeArray([!!child])
)
let inline reactElementWithChildren (name: string) (children: #seq<ReactElement>) =
React.createElement(
name,
createObj [],
!!children
)
let createElement name (properties: IReactProperty list) : ReactElement =
match properties |> List.partition (fun (key, _) -> key <> "children") with
| props, ["children", children] ->
React.createElement(
name,
createObj props,
!!children
)
| props, _ ->
React.createElement(
name,
createObj props,
ResizeArray()
)...and prop.children..:
`prop.children`
[<Erase>]
type prop =
/// Children of this React element.
static member inline children (value: ReactElement) : IReactProperty =
"children", value
/// Children of this React element.
static member inline children (elems: ReactElement seq) : IReactProperty =
"children", elemsAdvantages
- No legacy
Childrenapi - No element rebuilding
- no fable/feliz magic creating keys where no keys are written by the developer (i actually started with fable react and when i wrote my first js/ts react i was confused why i should write keys for all lists of elements)
Disadvantages
- No key warnings for dynamic lists
We cannot know if the ReactElement list was created only with static elements or with dynamic elements inside
Example
type Test =
[<NamedParamsAttribute>]
static member CreateReact(?init: int) : ReactElement =
let init = defaultArg init 0
let (count, setCount) = React.useState init
let increment = fun () -> setCount (count + 1)
let decrement = fun () -> setCount (count - 1)
Html.div [
Html.div [ // static
prop.text count
prop.key 1
]
Html.button [ prop.text "Increment"; prop.onClick increment ] // static
Html.button [ prop.text "Decrement"; prop.onClick decrement ] // static
for i in 0 .. count do // dynamic
Html.div i
]Solution 2: Custom .toArray function
The react Children.toArray function can be broken down to:
- Take existing element (exposes props and keys as members!)
- Create new element using these with an adjusted key
We can do this ourselves!
Below you can find the code for types and cloning ReactElement with a new key:
Details
[<ImportMember("react")>]
type [<AllowNullLiteral>] ReactElement =
abstract member key: string with get
abstract member ``type``: string with get
abstract member props: obj with get
abstract member ref: obj with get
abstract member _owner: obj with get
abstract member _store: obj with get
module ReactHelper =
open Fable.Core
open Fable.Core.JsInterop
let spreadPropsWith(currentProps:obj, newProps: obj) =
emitJsStatement (currentProps, newProps) """
return { ...$0, ...$1 };
"""
let cloneAndReplaceKey (element: ReactElement, newKey: string) =
let props = element.props
if isNullOrUndefined element.key then
let newProps = spreadPropsWith(props, createObj [|"key", newKey|])
React.createElement(element.``type``, newProps, ResizeArray())
else
element
let autoCreateIndices (children: #seq<ReactElement>) =
[|
for i in 0 .. (Seq.length children) - 1 do
cloneAndReplaceKey(Seq.item i children,sprintf "auto%i" i)
|]
autoCreateIndices (children: #seq<ReactElement>) : ReactElement [] can now be used in prop.children and reactElementWith Children to automatically index all ReactElements.
Advantages
- Recreates logic used in React 18, again all elements without a key get a key assigned.
Disadvantages
- Pseudo clones the react elements (which is - as far as i understand
Children.toArray- also currently done) - Fable/feliz magic adding keys the developer did not add themselves
Solution 3: prop.nested for static children
This idea was proposed by @Canna71 here
In addition to prop.children, add prop.nested.
Nested will be used as spread argument for React.createElement (will be static), children will be given as props.
Advantages
- User can decide what to use
Disadvantages
- Adding a feliz specific property
- Unable to define static and dynamic children in a mixed order. As you could have no option to mix them.
Say you want to do:
Html.div "Hello World" //static
for i in 0 .. 10 do
Html.div i //dynamic
Html.div "ByeBye!" //static
This would not be possible.
Solution 4: Computational Expression
Using a CE would allow us to directly feed different ReactNode types into our function.
From the official docs we can see that React nodes can be a lot of things
optional ...children: Zero or more child nodes. They can be any React nodes, including React elements, strings, numbers, portals, empty nodes (null, undefined, true, and false), and arrays of React nodes
We can represent this using f# computational expressions with different _.Yield members:
CE builder
type ReactBuilder(name: string) =
member inline _.Yield(text: string): BuilderFun -> unit =
fun (builder: BuilderFun) ->
builder !!text
member inline _.Yield(children: ReactElement list): BuilderFun -> unit =
fun (builder: BuilderFun) ->
builder (U2.Case1 !!children)
member inline _.Yield(child: ReactElement): BuilderFun -> unit =
fun (builder: BuilderFun) ->
builder (U2.Case1 !!child)
member inline _.Combine([<InlineIfLambdaAttribute>] first: BuilderFun -> unit, [<InlineIfLambdaAttribute>] second: BuilderFun -> unit): BuilderFun -> unit =
fun (builder: BuilderFun) ->
first builder
second builder
[<Erase>]
member _.Run(a: BuilderFun -> unit) : ReactElement =
let children = ResizeArray()
let props = ResizeArray()
let builderFun (x: U2<ReactChild, IReactProperty>) : unit =
match x with
| U2.Case1 child -> children.Add child
| U2.Case2 prop -> props.Add prop
a builderFun
React.createElement(name, props, children)
member inline x.Delay([<InlineIfLambda>] f: unit -> (BuilderFun -> unit)) = f()As computational expressions are only a (from my understanding) sequence of functions i decided to return a function from all Yields. This function takes whatever the input of the yield was and applies a to unit function on it. Then in _.Run this function is called and the different Yield inputs are sorted into two ResizeArrays.
let main () =
Html.div {
"Hello World"
Html.div { "Nested Div" }
[
for i in 0..10 do
Html.div { "Item " + i.ToString() }
]
}export function main() {
let second_1, first, child, second, children;
return ReactBuilder__Run_Z6C13D325(Html_div, (second_1 = ((first = ((child = ReactBuilder__Run_Z6C13D325(Html_div, (builder_1) => {
builder_1("Nested Div");
}), (builder_2) => {
builder_2(child);
})), (second = ((children = toList(delay(() => map((i) => {
let text_2;
return ReactBuilder__Run_Z6C13D325(Html_div, (text_2 = ("Item " + int32ToString(i)), (builder_3) => {
builder_3(text_2);
}));
}, rangeDouble(0, 1, 10)))), (builder_4) => {
builder_4(children);
})), (builder_5) => {
first(builder_5);
second(builder_5);
}))), (builder_6) => {
builder_6("Hello World");
second_1(builder_6);
}));
}There are two flavors to this. One is doing a Oxpecker approach in doing div( ..props ) { ..children }, the other is the onve shown above.
As the js code is not perfect, this solution could be improved by writing a CompilerPlugin similiar to Oxpecker (with createElement or JSX return value.
Solution 5: JSX?
This is a big questionmark, as it is propably more breaking than the other solutions (requiring fable to transpile to a different file extension).
Using the code written by Alfonso in Feliz.JSX, Feliz could move to a full JSX syntax. There is still some issues on fable compiler side.
End
Thats it, would love to help out with this, as i would like to move my app to react 19.
Thanks for @MangelMaxime for providing feedback during the investigation step!