toolup-forgetoolup-forge

0.4.0 — Adoption of `ToolUp.Elmish.*` packages (consumer-side sweep)

0.4.0 — Adoption of ToolUp.Elmish.* packages (consumer-side sweep)

0.4.3 update — Elmish runtime folded into ToolUp.Platform.Client. The three published packages ToolUp.Elmish, ToolUp.Elmish.React, and ToolUp.Elmish.HMR have been merged into ToolUp.Platform.Client. The namespace Elmish is preserved — every open Elmish, open Elmish.React, and open Elmish.HMR call site continues to compile unchanged — but the three separate PackageReferences + PackageVersion entries are gone. Consumers already on ToolUp.Elmish.* 0.4.0–0.4.2 update by dropping those PackageReference / PackageVersion lines and verifying they still take a PackageReference on ToolUp.Platform.Client; the runtime arrives transitively. Consumers still on Fable.Elmish.* skip the intermediate ToolUp.Elmish.* hop and follow the diff in § Diff to apply (per consumer) — 0.4.3 path below. The rationale below explains the 0.4.0 swap; the 0.4.3 update is purely structural — the runtime stays the same.


toolup-forge 0.4.0 swaps the upstream Elmish family for the forked ToolUp.Elmish.* family. The runtime / wire shape is preserved unchanged from upstream Fable.Elmish v5.x — existing open Elmish call sites compile against the new packages with no F# source edits.

What changes

Three <PackageReference> entries:

- <PackageReference Include="Fable.Elmish" />
- <PackageReference Include="Fable.Elmish.React" />
- <PackageReference Include="Fable.Elmish.HMR" />
+ <PackageReference Include="ToolUp.Elmish" />
+ <PackageReference Include="ToolUp.Elmish.React" />
+ <PackageReference Include="ToolUp.Elmish.HMR" />

And three corresponding <PackageVersion> entries in the consumer's Directory.Packages.props (point to 0.4.0).

Why

Upstream Elmish is a healthy 8-year-old project, but the ToolUp surface has accumulated patterns that the upstream record doesn't carry:

  • Every multi-module Elmish shell re-invents the let mutable shellDispatch : (Msg -> unit) option = None + Cmd.ofEffect (fun d -> shellDispatch <- Some d) capture pattern. ToolUp.Elmish ships IDispatcher<'msg> + Program.withDispatcherHandle so the dance is captured in one primitive — and IDispatcher.IsActive flips to false on termination so background callbacks no-op on hot-reload rather than dispatching against a torn-down loop.
  • Boot-time multi-source prefetch gating ("load Configs in parallel with Flags, fire ReinitActiveModule when the last one resolves") needs ad-hoc IsConfigsPending / IsFlagsPending bookkeeping fields. Prefetch<'a> + Prefetch.onAllReady codify the pattern.
  • The upstream (string * exn) -> unit onError shape loses every piece of context that matters — which phase raised, which module, which correlation id. ErrorContext + Program.withErrorReporter give the reporter all of it.
  • Background subscriptions (SSE, notifications, navigation requests) wired via Cmd.ofEffect are never torn down — they leak across hot reloads. EffectHandle<'msg> + Program.withEffect carry an explicit Lifetime (Program / Module of moduleId / Manual) so the runtime + HMR can dispose them cleanly.
  • The dominant RPC pattern (Cmd.OfAsync.either api.Method arg OkMsg ErrMsg) deserves an intent-named name. Cmd.OfRemoting.{call, callWithRetry} provides it, with a RetryPolicy knob for transient transport failures.
  • The unused-by-ToolUp surface (Cmd.OfFunc, Cmd.OfPromise, Cmd.OfTask, Cmd.OfValueTask, Cmd.OfAsyncWith, Cmd.OfAsyncImmediate, WebSharper paths, cmd.obsolete.fs v3.x shims) is dropped. The migration cost is zero — no observed call sites across forge or any downstream consumer in our audit set.

Diff to apply (per consumer)

Step 1 — Directory.Packages.props

- <PackageVersion Include="Fable.Elmish" Version="5.0.2" />
- <PackageVersion Include="Fable.Elmish.HMR" Version="9.0" />
- <PackageVersion Include="Fable.Elmish.React" Version="5.6" />
+ <PackageVersion Include="ToolUp.Elmish" Version="0.4.0" />
+ <PackageVersion Include="ToolUp.Elmish.HMR" Version="0.4.0" />
+ <PackageVersion Include="ToolUp.Elmish.React" Version="0.4.0" />

Step 2 — every .fsproj referencing the upstream packages

- <PackageReference Include="Fable.Elmish" />
- <PackageReference Include="Fable.Elmish.HMR" />
- <PackageReference Include="Fable.Elmish.React" />
+ <PackageReference Include="ToolUp.Elmish" />
+ <PackageReference Include="ToolUp.Elmish.HMR" />
+ <PackageReference Include="ToolUp.Elmish.React" />

Step 3 — source code

No source edits required for the swap itself. open Elmish, Cmd.batch, Cmd.OfAsync.either, Program.mkProgram, Program.withReactSynchronous, Program.withSubscription, Cmd.map, Cmd.ofMsg — all unchanged.

Compile errors that would indicate non-trivial migration work:

  • error FS0039: ... 'OfFunc' is not defined → replace Cmd.OfFunc.either f arg ok err with Cmd.ofEffect (fun dispatch -> try dispatch (ok (f arg)) with ex -> dispatch (err ex)). Cmd.OfFunc.perform and Cmd.OfFunc.attempt map to the same shape minus the ok/err branch.
  • error FS0039: ... 'OfPromise' is not definedCmd.OfAsync.* via Async.AwaitPromise.
  • error FS0039: ... 'OfTask' is not definedCmd.OfAsync.* via Async.AwaitTask.
  • error FS0039: ... 'OfValueTask' is not defined → as OfTask.
  • error FS0039: ... 'OfAsyncWith' / 'OfAsyncImmediate' is not defined → drop the start parameter; the default Async.Start (Fable: Async.StartImmediate) is correct for every observed call site.
  • warning FS0044: ... 'withConsoleTrace' is deprecated → ignore for now; the function still works as a compat shim. For new code use Program.withErrorReporter + a per-update interceptor.

Verification

dotnet build <consumer>.sln           # must succeed clean
# Fable side (catches issues `dotnet build` misses):
cd <client-project> && dotnet fable -o output --noCache

Both pass clean against forge's samples/MinimalClient after the swap; the swap demonstrably preserves runtime-shape compatibility.

Optional follow-up — adopt the new primitives

The swap is the load-bearing step. Adopting the new primitives is incremental and each migration is small. forge has worked one example already (the IDispatcher adoption in src/ToolUp.Platform.Client/Client/SDK.Client.fs); the others are documented as forge follow-up phases:

  • IDispatcher adoption — delete let mutable shellDispatch; install via Program.withDispatcherHandle at the run point; use IDispatcher.IsActive in background callbacks. Landed in forge SDK 0.4.0 (SDK.Client.fs:376,462,605,2014).
  • Prefetch adoption — replace IsConfigsPending / IsFlagsPending bookkeeping with Prefetch<Map<...>> typed fields; use Prefetch.onAllReady in the ConfigsLoaded / FlagsLoaded handlers. Forge follow-up phaseSDK.Client.fs:487-505 (boot) + :772-808 (team switch).
  • Structured ErrorContext — wire Program.withErrorReporter; populate ModuleId via ErrorContext.withModule in the per-module boundary. Forge follow-up phaseClientConfig.OnError + Components.ModuleBoundary.wrap.
  • EffectHandle lifetimes — convert SSE / Notifications / NavigationRequest from Cmd.ofEffect (fun d -> ...) to Program.withEffect (EffectHandle.programLifetime "<id>" (fun d -> ...)). Forge follow-up phaseSDK.Client.fs:619-643.
  • Cmd.OfRemoting.call — sweep Cmd.OfAsync.either api.MethodCmd.OfRemoting.call api.Method. Pure rename for the common case; opens the door to per-call retry policies and tracing interceptor recognition. Forge follow-up phase — every module's API call sites.

Each follow-up is bounded and can ship as its own incremental commit; the SDK.Client.fs build remains green between them. Downstream consumer apps should sequence their adoption similarly: swap first, then adopt primitives one at a time.

Rollback

The package swap is reversible by reverting the <PackageReference> and <PackageVersion> edits — all consumer code that calls the upstream-shape API continues to work against upstream Fable.Elmish. Any code that has adopted the new primitives (IDispatcher, Prefetch, structured ErrorContext, EffectHandle, Cmd.OfRemoting) needs to be reverted to the pre-adoption shape, since those primitives don't exist in upstream Elmish.

0.4.3 path (consumers on Fable.Elmish.* still)

A consumer on upstream Fable.Elmish.* who has not yet adopted the 0.4.0 swap can skip the intermediate ToolUp.Elmish.* hop and jump straight to 0.4.3. The end state is the same: open Elmish continues to compile, the runtime ships as part of ToolUp.Platform.Client, and the new primitives (IDispatcher, Prefetch, structured ErrorContext, EffectHandle, Cmd.OfRemoting) are available.

Step 1 — Directory.Packages.props

Drop the three upstream PackageVersion entries; do NOT add ToolUp.Elmish.* entries:

- <PackageVersion Include="Fable.Elmish" Version="5.0.2" />
- <PackageVersion Include="Fable.Elmish.HMR" Version="9.0" />
- <PackageVersion Include="Fable.Elmish.React" Version="5.6" />

Step 2 — every .fsproj referencing the upstream packages

Drop the upstream PackageReferences. Verify the project has a PackageReference (or ProjectReference) on ToolUp.Platform.Client — that's where the elmish runtime arrives from in 0.4.3:

- <PackageReference Include="Fable.Elmish" />
- <PackageReference Include="Fable.Elmish.HMR" />
- <PackageReference Include="Fable.Elmish.React" />
  <PackageReference Include="ToolUp.Platform.Client" />   <!-- existing, unchanged -->

If a project depended on Fable.Elmish.* for the Elmish runtime but did NOT depend on ToolUp.Platform.Client, that's a structural smell — the elmish runtime is a forge implementation detail now, not a standalone library. Either add a PackageReference on ToolUp.Platform.Client, or extract the relevant Elmish source into the project directly. There are no remaining published ToolUp.Elmish.* packages to take a direct PackageReference on.

Step 3 — source code

Same as the 0.4.0 swap: no source edits required. open Elmish, open Elmish.React, open Elmish.HMR, Cmd.*, Program.*, all unchanged. The compile-time error signals listed in the 0.4.0 Step 3 above all still apply — they indicate non-trivial migration work from APIs upstream Fable.Elmish supported that this fork dropped (Cmd.OfFunc, Cmd.OfPromise, Cmd.OfTask, Cmd.OfValueTask, Cmd.OfAsyncWith, Cmd.OfAsyncImmediate).

Step 4 — verify

dotnet build <consumer>.sln           # must succeed clean
# Fable side:
cd <client-project> && dotnet fable -o output --noCache

Same verification cadence as the 0.4.0 swap.

0.4.3 consumers already on ToolUp.Elmish.* 0.4.0–0.4.2

Drop the three ToolUp.Elmish.* PackageReference + PackageVersion entries. Confirm a PackageReference on ToolUp.Platform.Client exists. Build. The end state is identical to the 0.4.3 path above.

- <PackageVersion Include="ToolUp.Elmish" Version="0.4.2" />
- <PackageVersion Include="ToolUp.Elmish.HMR" Version="0.4.2" />
- <PackageVersion Include="ToolUp.Elmish.React" Version="0.4.2" />

- <PackageReference Include="ToolUp.Elmish" />
- <PackageReference Include="ToolUp.Elmish.HMR" />
- <PackageReference Include="ToolUp.Elmish.React" />

No source-code changes are required.