Skip to content

Conversation

@mjblacker
Copy link
Contributor

This fixes issue with the useElmish hook not working with SSG.

I removed the custom JS interop (maybe not the best idea) to keep it consistent which also affected how the base functions for useSyncExternalStore worked.

Tests and Docs (in build) were building for me along with another test project.
Feedback welcome, especially on the delegates.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes the useElmish hook to support Static Site Generation (SSG) by refactoring how useSyncExternalStore interacts with F# functions. The key changes involve removing custom JavaScript interop and introducing delegate types for better SSG compatibility.

Key changes:

  • Introduced UseSyncExternalStoreSubscribe and UseSyncExternalStoreSnapshot<'T> delegate types for type-safe React interop
  • Refactored React.useSyncExternalStore overloads to use delegates instead of Func<> types
  • Updated UseElmish.fs to use the Feliz namespace and new delegate types, eliminating custom JS interop

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
tests/Feliz/ReactBindings/UseSyncExternalStore.test.fs Removed extra blank line (whitespace cleanup)
src/Feliz/React/ReactTypes.fs Added delegate type definitions for useSyncExternalStore
src/Feliz/React/React.fs Updated useSyncExternalStore overloads to use new delegate types
src/Feliz/React/FsReact.fs Added helper function to create UseSyncExternalStoreSubscribe delegate
src/Feliz.UseElmish/UseElmish.fs Removed custom JS interop, now uses Feliz API with delegates
src/Feliz.UseElmish/Feliz.UseElmish.fsproj Added project reference to Feliz.fsproj
docs/docs/ecosystem/04_Hooks/Feliz.UseElmish.mdx Updated to show live example with BrowserOnly wrapper for SSG support
Comments suppressed due to low confidence (1)

src/Feliz/React/React.fs:664

  • In the overload for useSyncExternalStore that accepts IDisposable, the getSnapshot parameter should also be converted to the delegate type. Currently, it's expecting UseSyncExternalStoreSnapshot<'T> but when calling with a function, the caller would need to wrap it in the delegate. Consider adding an overload that accepts (unit -> 'T) for getSnapshot and converts it internally, similar to how subscribe is handled.
    static member inline useSyncExternalStore(subscribe: (unit -> unit) -> #IDisposable, getSnapshot: UseSyncExternalStoreSnapshot<'T>, ?getServerSnapshot: UseSyncExternalStoreSnapshot<'T>): 'T =
        React.useSyncExternalStore( UseSyncExternalStoreSubscribe
            (fun (callback) ->
                let disp = subscribe(callback)
                fun () -> disp.Dispose()
            ), 
            getSnapshot,
            ?getServerSnapshot = getServerSnapshot
        )

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

<CodeBlock language="fsharp" showLineNumbers>
{RawElmishCounter}
</CodeBlock>
<ComponentRender code={RawElmishCounter}><ElmishCounter /></ComponentRender>
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation shows duplicate ComponentRender elements rendering the same component. Line 37 should be removed as it's redundant with the BrowserOnly wrapped version below it. The BrowserOnly wrapper is the correct approach for components that don't support SSR.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only single one now

Comment on lines +647 to +648
static member inline useSyncExternalStore(subscribe: (unit -> unit) -> (unit -> unit), getSnapshot: UseSyncExternalStoreSnapshot<'T>, ?getServerSnapshot: UseSyncExternalStoreSnapshot<'T>): 'T =
React.useSyncExternalStore( UseSyncExternalStoreSubscribe subscribe, getSnapshot, ?getServerSnapshot = getServerSnapshot)
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the overload for useSyncExternalStore that accepts an F# function signature, the getSnapshot parameter should also be converted to the delegate type. Currently, it's expecting UseSyncExternalStoreSnapshot<'T> but when calling with a function, the caller would need to wrap it in the delegate. Consider adding an overload that accepts (unit -> 'T) for getSnapshot and converts it internally, similar to how subscribe is handled.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this works with the optional parameter. Not really sure how it works with the question mark on the sender side.

token

static member inline createSyncExternalStoreSubscribe(subscribe: (unit -> unit) -> (unit -> unit)) : UseSyncExternalStoreSubscribe =
UseSyncExternalStoreSubscribe(subscribe)
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a helper function to create UseSyncExternalStoreSnapshot delegate, similar to createSyncExternalStoreSubscribe, to provide a consistent API for users who need to create these delegates manually.

Suggested change
UseSyncExternalStoreSubscribe(subscribe)
UseSyncExternalStoreSubscribe(subscribe)
static member inline createSyncExternalStoreSnapshot<'Snapshot>(getSnapshot: unit -> 'Snapshot) : UseSyncExternalStoreSnapshot<'Snapshot> =
UseSyncExternalStoreSnapshot(getSnapshot)

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've removed the helper as it seemed unnecessary and more verbose

if state.IsOutdated(arg, dependencies) then
ElmishState(program, arg, dependencies) |> setState
let finalState, dispatch, subscribed, queuedMessages = useSyncExternalStore(state.Subscribe, (fun () -> state.State), None)
let finalState, dispatch, subscribed, queuedMessages = React.useSyncExternalStore(state.Subscribe, UseSyncExternalStoreSnapshot(fun () -> state.State), UseSyncExternalStoreSnapshot(fun () -> state.State))
Copy link

Copilot AI Dec 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same getServerSnapshot value (state.State) is being used for both the client and server. This defeats the purpose of SSG support. For SSG to work correctly, getServerSnapshot should typically return a stable initial state or handle the case differently from the client-side snapshot. Using the same value for both parameters means SSG/SSR will still encounter the same issues this PR is meant to fix.

Suggested change
let finalState, dispatch, subscribed, queuedMessages = React.useSyncExternalStore(state.Subscribe, UseSyncExternalStoreSnapshot(fun () -> state.State), UseSyncExternalStoreSnapshot(fun () -> state.State))
let initialState = state.State
let finalState, dispatch, subscribed, queuedMessages =
React.useSyncExternalStore(
state.Subscribe,
UseSyncExternalStoreSnapshot(fun () -> state.State),
UseSyncExternalStoreSnapshot(fun () -> initialState)
)

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From my tests and understanding this should be fine as the initial value should always be the same that is passed in from the init function.


import ComponentRender from '@site/src/components/ComponentRender';
import CodeBlock from '@theme/CodeBlock';
import BrowserOnly from '@docusaurus/BrowserOnly';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you correctly handle the getServerSnapshot variable it should work without BrowserOnly component https://react.dev/reference/react/useSyncExternalStore

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

optional getServerSnapshot: A function that returns the initial snapshot of the data in the store. It will be used only during server rendering and during hydration of server-rendered content on the client. The server snapshot must be the same between the client and the server, and is usually serialized and passed from the server to the client. If you omit this argument, rendering the component on the server will throw an error

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this was from an earlier test to just show the docs before I attempted making useElmish for SSG. Updated docs to remove duplicate and import.

@mjblacker mjblacker requested a review from Freymaurer December 19, 2025 10:50
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.

2 participants