Skip to content

Migrating to Solid 2

Partas.Solid 3.0 targets Solid 2.0 (2.0.0-rc.9); 2.x targeted Solid 1.9. Solid 2 renamed, removed or reworked much of its runtime API, so expect to touch every component that does more than render markup. Each section below shows the 2.x code and what replaces it. For the full 3.0 API, see Solid-JS.

Checklist

  1. Update the packages: Partas.Solid and Partas.Solid.FablePlugin 3.0, solid-js and @solidjs/web 2.0. The plugin needs Fable 5.
  2. Rename .classList to .class'. Replace .on (...), .prop (...) and .use' (...) calls.
  3. Rename the control-flow components: ErrorBoundary to Errored, Suspense to Loading, SuspenseList to Reveal, For to For.Keyed or For.NonKeyed. Drop Index.
  4. Replace splitProps and mergeProps with omit and merge.
  5. Split each createEffect into a compute function and an effect function. Return cleanups from the effect instead of calling onCleanup in it.
  6. Rework code built on createResource, batch, onMount, createSelector, createComputed, startTransition, useTransition, produce or unwrap.
  7. Pass store setters an updater: setStore (fun s -> ...). Change solid-js/store imports to solid-js.
  8. Write two-argument callbacks (mapArray, createErrorBoundary) as curried lambdas.
  9. Add open Partas.Solid.Web where you use render, hydrate, renderToString, isServer, Portal or Dynamic.
  10. Rewrite batch { } and selector { } blocks. Give each effect { } block a let!.
  11. Regenerate any committed JSX snapshots.

Props: omit and merge

Solid 2 replaced splitProps and mergeProps with omit and merge. omit returns only the rest of the props, so there is no "local" half any more.

For [<SolidTypeComponent>] members your F# does not change; only the generated JSX does:

2.x output3.0 output
const [PARTAS_LOCAL, PARTAS_OTHERS] = splitProps(props, ["class"])const PARTAS_OTHERS = omit(props, "class")
PARTAS_LOCAL.classprops.class
props = mergeProps({ class: "x" }, props)props = merge({ class: "x" }, props)
import { splitProps, mergeProps } from "solid-js"import { omit, merge } from "solid-js"

When a component reads no props, the plugin drops the omit call and its import:

export function MyTag(props) {
    const PARTAS_OTHERS = props;
    return <div />;
}

If you called splitProps or mergeProps yourself, call omit and merge instead:

// 2.x
let local, others = splitProps (props, [| "class"; "size" |])
let withDefaults = mergeProps ({| size = "md" |}, props)

// 3.0
let others = omit (props, "class", "size")
let withDefaults = merge ({| size = "md" |}, props)

Read the omitted props straight from props.

The self identifier can have any name

In 2.x the self identifier of a [<SolidTypeComponent>] member had to be props. In 3.0 any name works, and it appears in the JSX as written:

[<Erase>]
type Button() =
    interface RegularNode
    [<Erase>] member val size: string = unbox null with get, set
    [<Erase>] member val variant: string = unbox null with get, set

    [<SolidTypeComponent>]
    member this.constructor =
        button (class' = $"btn {this.size} {this.variant}").spread this
export function Button(this$) {
    const PARTAS_OTHERS = omit(this$, "size", "variant");
    return <button class={`btn ${this$.size} ${this$.variant}`} {...PARTAS_OTHERS} n$={false} />;
}

The member must still be an instance member with a single unit parameter, on a type in the Partas.Solid namespace. See SolidTypeComponent.

New component flags

Two new ComponentFlags control the props preamble:

  • ComponentFlag.SkipOmit leaves out the omit call.
  • ComponentFlag.SpreadProps makes .spread spread the self identifier instead of PARTAS_OTHERS.

With either flag, .spread props compiles to {...props}, so a wrapper can pass its props on without paying for omit. For.Keyed is defined this way:

[<SolidTypeComponent(ComponentFlag.SkipOmit ||| ComponentFlag.SpreadProps)>]
member props.comp = For(keyed = !^true).spread props

See Attribute flags. The existing flags are unchanged.

The spread marker

A spread used to emit a bool:-prefixed marker prop. The prefix is gone:

 <div {...PARTAS_OTHERS} bool:n$={false}>
 <div {...PARTAS_OTHERS} n$={false}>

This only matters if you post-process the generated JSX.

Extension methods

2.x3.0
.classList (obj).class' (obj)
.on (name, handler)Removed
.prop (name, value)Removed
.use' (name, value)Removed. Solid 2 has no use: directives.
.bool (name, value), emitting bool:name.bool (name, value), emitting name
.spread on #HtmlTag.spread on #HtmlElement

.attr, .data, .ref, .style', .class', .bool and .spread remain. Of those, only .data still adds a prefix (data-name).

// 2.x
div().classList {| active = isActive () |}

// 3.0
div().class' {| active = isActive () |}

.on and .use' have no direct replacement. Use the typed event attributes (onClick = ...), or an OnHandler for once, passive and capture. For a directive, pass createDirectiveFactory to .ref:

// 2.x
input().use' ("autofocus", true)

// 3.0
let autofocus = createDirectiveFactory (fun (el: Browser.Types.HTMLInputElement) ->
    el.focus ()
    ignore)

input().ref autofocus

See Extension methods.

Control flow

2.x3.0
ErrorBoundaryErrored
SuspenseLoading
SuspenseListReveal
For<'T>For.Keyed<'T>, For.NonKeyed<'T> or For.KeyedFn<'T> (For.Component<'T> is For.Keyed)
Index<'T>For.NonKeyed<'T>
Show, Switch, MatchUnchanged, plus Show.Keyed, Show.NonKeyed, Match.Keyed and Match.NonKeyed
noneRepeat, which renders by count instead of by array
Portal, Dynamic in Partas.SolidThe same names in Partas.Solid.Web

For and Index

// 2.x
For(each = items ()) {
    yield fun item index -> li () { item.name }
}

Index(each = names ()) {
    yield fun name index -> li () { name () }
}

// 3.0
For.Keyed(each = items ()) {
    yield fun item index -> li () { item.name }
}

For.NonKeyed(each = names ()) {
    yield fun name index -> li () { name () }
}

For.Keyed gives the row the item and an index accessor. For.NonKeyed gives it an item accessor and a plain index.

ErrorBoundary to Errored

The fallback function now gets the error as an accessor, and fallback is a union of an element and a function, so the function needs !^. plainFallback is gone.

// 2.x
ErrorBoundary(fallback = ErrorBoundary.Fallback(fun err reset -> div () { string err })) {
    Risky ()
}

// 3.0
Errored(
    fallback =
        !^(ErrorBoundary.Fallback(fun err reset ->
            div () { (err () :?> exn).Message }))
) {
    Risky ()
}

Errored also has fallbackFn and fallbackEle setters, which set fallback without the !^.

Suspense and SuspenseList

// 2.x
SuspenseList(revealOrder = SuspenseList.RevealOrder.Forwards) {
    Suspense(fallback = Spinner ()) { Profile () }
    Suspense(fallback = Spinner ()) { Posts () }
}

// 3.0
Reveal(order = Reveal.Order.Sequential) {
    Loading(fallback = Spinner ()) { Profile () }
    Loading(fallback = Spinner ()) { Posts () }
}

Removed and replaced primitives

2.x3.0
createResource and its typesAn async createMemo, under Loading. refresh re-runs it.
createComputedA memo, or a writable derived signal (createSignal (fun () -> ...))
createDeferredNone
createSelectorcreateProjection
batchflush. Writes are always batched.
startTransition, useTransitionaction, with isPending
onMountonSettled
onThe compute half of createEffect
catchErrorcreateErrorBoundary
indexArraymapArrayUnkeyed
produceThe store setter: mutate the draft and return it
unwrapsnapshot
SolidStoreSetter, SolidStorePathStoreSetter<'T>
importComponentimportDynamic with lazy'
getListenergetObserver

createResource

// 2.x
let user, manager = createResource (userId, fetchUser)

Suspense(fallback = p () { "Loading..." }) {
    p () { user.current.name }
}

// 3.0
let user = createMemo (fun (_: User option) -> fetchUser (userId ()))

Loading(fallback = p () { "Loading..." }) {
    p () { user().name }
}

An async memo suspends its readers until the promise settles, so you read the value directly. refresh user replaces manager.refetch (). isPending (fun () -> box (user ())) tells you a refetch is in flight.

onMount and batch

// 2.x
onMount (fun () -> inputRef.focus ())

batch (fun () ->
    setFirst "Ada"
    setLast "Lovelace")

// 3.0
onSettled (fun () -> inputRef.focus ())

// Writes are batched anyway. Use flush to apply them now.
flush (fun () ->
    setFirst "Ada"
    setLast "Lovelace")

Effects have two phases

Solid 2 has no single-function createEffect. An effect is a tracked compute function, which returns a value, and an untracked effect function, which gets it.

// 2.x
createEffect (fun () ->
    console.log $"count is {count ()}")

// 3.0
createEffect (
    (fun (_: int option) -> count ()),
    fun (n: int) -> console.log $"count is {n}"
)

Return a cleanup from the effect function instead of calling onCleanup inside it. createEffect has overloads for an effect function that returns a cleanup:

// 2.x
createEffect (fun () ->
    let id = JS.setInterval tick (delayMs ())
    onCleanup (fun () -> JS.clearInterval id))

// 3.0
createEffect (
    (fun (_: int option) -> delayMs ()),
    fun (ms: int) ->
        let id = JS.setInterval tick ms
        fun () -> JS.clearInterval id
)

on (deps, fn) is gone as well: the compute function already names what the effect depends on. When you cannot separate the two phases, createTrackedEffect (fun () -> ...) runs one function that both tracks and acts.

Stores

Stores moved into solid-js; there is no solid-js/store module. The setter takes a single updater, 'T -> 'T. Path setters and produce are gone, because mutating the draft is now the default. Mutate the draft and return it, or return a new value:

// 2.x
setState.Path.Map(_.todos).Update (fun todos -> Array.append todos [| todo |])
setState.Update (produce (fun s -> s.count <- s.count + 1))

// 3.0
setState (fun s -> {| s with todos = Array.append s.todos [| todo |] |})

setState (fun s ->
    s.count <- s.count + 1
    s)

createStore now returns a Store<'T> rather than a bare 'T. Read it through .Value: state.Value.todos. unwrap state is now snapshot state. reconcile next returns an updater, so setState (reconcile next) still works.

Curried callbacks

The two-argument callbacks of mapArray and createErrorBoundary are now typed as Func<_, _, _>. Write them as curried lambdas, fun item index -> ..., which F# converts to a two-argument JS function. The 2.x signatures compiled to a one-argument JS function, so Solid never passed the index or the reset function.

// map an array, with the index
let labels = mapArray (items, fun item index -> $"{index ()}: {item.name}")

// an error boundary, with its reset function
let view = createErrorBoundary ((fun () -> Risky ()), fun err reset -> Fallback (err ()) reset)

lazy'

lazy' follows Solid 2's lazy (fn, options?, moduleUrl?). importComponent is gone. Use Fable's importDynamic:

// 2.x
let Settings = lazy' (fun () -> importComponent "./Settings.fs.jsx")

// 3.0
let Settings = lazy' (fun () -> importDynamic "./Settings.fs.jsx")

// a named export instead of the default one
let Panel = lazy' ((fun () -> importDynamic "./Panels.fs.jsx"), LazyOptions (``export`` = "Panel"))

The web runtime

solid-js/web is now @solidjs/web. Its bindings are in the Partas.Solid.Web namespace: render, hydrate, renderToString, renderToStream, isServer, isDev, clientOnly, httpHeader, httpStatus, Portal, Dynamic and HeadTag.

// 2.x
open Partas.Solid

render ((fun () -> App ()), document.getElementById "root")

// 3.0
open Partas.Solid
open Partas.Solid.Web

render ((fun () -> App ()), document.getElementById "root")

Install @solidjs/web next to solid-js.

Experimental builders

The builders in Partas.Solid.Experimental follow the new primitives:

  • effect { } needs exactly one let!, which names the tracked source. The rest of the block is the effect.
  • mount { } wraps onSettled.
  • lazyload { } takes importDynamic.
  • batch { } and selector { } are gone. Call flush and createProjection directly.
// 2.x
effect {
    console.log $"count is {count ()}"
}

// 3.0
effect {
    let! n = count
    console.log $"count is {n}"
}

See Experimental features.

Snapshots and generated JSX

If you commit the generated JSX, or compare against it in tests, regenerate it. Expect:

  • PARTAS_LOCAL is gone. Props are read from the self identifier.
  • splitProps and mergeProps become omit and merge, and the imports change to match.
  • bool:n$ becomes n$.
  • Control-flow imports change: Errored, Loading, Reveal from solid-js, and KeyedFor / NonKeyedFor from Partas.Solid's compiled bindings (SolidBindings.fs.jsx).
  • Anything from the web runtime is imported from @solidjs/web.

See JSX output for how the plugin output is laid out.

Edit this page