Important: This documentation covers Yarn 1 (Classic).
For Yarn 2+ docs and migration guide, see yarnpkg.com.

Package detail

crux-wrapper

atomrc1.1kISC3.3.0TypeScript support: included

A React provider for your crux application

crux, react

readme

crux-wrapper

A set of tools to make using a Crux application as if it was an npm package. It brings:

  • the possibility to await an event that was sent to your crux application
  • a react Provider that will allow you to use your crux app as if it was a redux store
  • the is function that allows easy typeguarding of payloads coming from the crux app

Table of content:

Installation

npm install crux-wrapper

Usage with react

The react package allows you to have access to 2 highly useful hooks:

  • useViewModel to subscribe to the changes of the view model
  • useDispatch to send events to the crux app

To setup the react provider, you first need to instantiate the CoreProvider like so

import { CoreProvider } from "crux-wrapper/react";

// All the imports below are from your crux app. They are needed so that the crux-wrapper knows how to talk to your core
import init, * as core from "shared";
import { ViewModel, Request } from "shared_types/types/core_types";
import {
  BincodeSerializer,
  BincodeDeserializer,
} from "shared_types/bincode/mod";

// Will tell typescript what the final view model is. It will allow correctly typing the useViewModel hook
declare module "crux-wrapper/react" {
  type CoreViewModel = ViewModel;
}

export function App() {
  const coreConfig = {
    // The wasm init function that will expose your core's API
    init: () => init().then(() => core),
    // the handler that will be passed all the effects the core needs to perform
    onEffect: async (
      effect,
      {
        // send a response to the core. This function is tied to the effect send, you don't need to pass the effect id. This function could also be used for streaming responses back
        respond,
        // to send a new event to the core (not a response)
        send,
        // get the latest view model
        view,
      },
    ) => {
      /*...*/
    },
    serializerConfig: {
      BincodeSerializer,
      BincodeDeserializer,
      ViewModel,
      Request,
    },
  };

  return (
    <CoreProvider
      coreConfig={coreConfig}
      initialState={new ViewModel(BigInt(0))}
      RenderEffect={EffectVariantRender}
    >
      <Counter />
    </CoreProvider>
  );
}

Once this is setup you can use the useViewModel and useDispatch hooks in all the children comopnents:

function Counter() {
  const viewModel = useViewModel(); // Will subscribe to any changes to the viewmodel
  const dispatch = useDispatch();

  return (
    <div>
      <p>Count: {viewModel.count}</p>
      <button onClick={() => dispatch(new EventVariantIncrement())}>
        Increment
      </button>
      <button onClick={() => dispatch(new EventVariantDecrement())}>
        Decrement
      </button>
    </div>
  );
}

Being able to await for event allows you, for example, to use useTransition to show a loader when something is being processed

import { useState, useTransition } from "react";

function Counter() {
  const viewModel = useViewModel();
  const dispatch = useDispatch();
  const [isPending, startTransition] = useTransition();

  if (isPending) {
    return "loading...";
  }

  return (
    <div>
      <p>Count: {viewModel.count}</p>
      <button
        onClick={() => {
          startTransition(() => dispatch(new EventVariantIncrement()));
        }}
        disabled={isPending}
      >
        Increment
      </button>
      <button
        onClick={() => {
          startTransition(() => dispatch(new EventVariantDecrement()));
        }}
        disabled={isPending}
      >
        Decrement
      </button>
    </div>
  );
}

Typescript helper

When receiving payloads from the crux app, you often have to compare the class of the object to the prototypes of the classes you expect. Something along the lines of paylaod instanceof CruxEventVariant. This is cumbersome and doesn't provide correct type checks.

The is function bring both a nice way to check what a crux payload is + type narrowing for typescript application

Suppose you are using crux_time and you need to handle those 2 requests:

NotifyAt { id: TimerId, instant: Instant },
NotifyAfter { id: TimerId, duration: Duration },

This is how you'd do it with the is function:

import { is } from "crux-wrapper";

switch (true) {
  case is(request, TimeRequestVariantNotifyAt): {
    const { id, instant } = request;
    //                     ^? TimeRequestVariantNotifyAt
  }
  case is(payload, TimeRequestVariantNotifyAfter):
    const { id, duration } = request;
  //                      ^? TimeRequestVariantNotifyAfter
}

For comparison this is what you'd get only using switch on the constructor and instanceof: Property 'instant' does not exist on type 'TimeRequest'

switch (request.constructor) {
  case request instanceof TimeRequestVariantNotifyAt: {
    const { id, instant } = payload;
    // ❌ Property 'instant' does not exist on type 'TimeRequest'
  }
  case request instanceof TimeRequestVariantNotifyAfter:
    const { id, duration } = payload;
  // ❌ Property 'duration' does not exist on type 'TimeRequest'
}

Running your crux app in a web worker

Web workers allow you to run scripts in background threads, which can be useful for offloading heavy computations or tasks that would otherwise block the main thread. In the context of a crux application, you can run your app logic in a web worker to keep the UI responsive.

This is how you would use the wrap (same for the react version) function to run your crux app in a web worker:

note: the following example is based on comlink that I highly recommend for web workers.

// webworker.ts
import type { Endpoint } from "comlink";
import { expose } from "comlink";
import init, { handle_response, process_event, view } from "core";
import wasmPath from "core/core_bg.wasm?url";

const api = {
  // The worker just has to define a function that will trigger the loading of the wasm module
  init: async () => {
    await init({ module_or_path: wasmPath });
  },
  process_event,
  handle_response,
  view,
};
export type CoreWorkerApi = typeof api;
expose(api, self as Endpoint);

The only differences between a core running in the main thread and one running in a web worker is the init function, that is a slight variant.

See the example project for a full working example.

import { wrap } from "crux-wrapper";

import init, * as core from "shared";
import { ViewModel, Request } from "shared_types/types/core_types";
import {
  BincodeSerializer,
  BincodeDeserializer,
} from "shared_types/bincode/mod";

const app = wrap({
  init: () => {
    const worker = wrap<CoreWorkerApi>(
      new Worker(new URL("./worker.ts", import.meta.url), {
        type: "module",
      }),
    );
    // We call the init, to make sure the worker loads the wasm module
    await worker.init();
    return worker;
  },
  onEffect: async () => {
    /*...*/
  },
  serializerConfig: {
    BincodeSerializer,
    BincodeDeserializer,
    ViewModel,
    Request,
  },
});

Now with those changes, all your payload will go through the webworker and the crux core will be leaving the main thread alone.

Usage in a VanillaJS app

You can also use the crux-wrapper without react. By using the wrap function exposed, you get the benefit of being able to await events that are sent to your crux application.

import { wrap } from "crux-wrapper";

const app = wrap({
  init,
  onEffect: async () => {
    /*...*/
  },
  serializerConfig: {
    BincodeSerializer,
    BincodeDeserializer,
    ViewModel,
    Request,
  },
});

await app.init(); // Initialize the crux app (will load the wasm module)
await app.sendEvent(new EventVariantIncrement()); // Send an event to the crux app
// At this point you know that all the effects initiated by the event have been fully processed

Testing

You probably would want to test your react components with the ability to mock the crux app. The crux-wrapper provides a MockCoreProvider that you can use to mock the core app.

Here is an example that uses @testing-library/react to render a component with the mocked core:

import { render } from "@testing-library/react";
import { MockCoreProvider, State } from "crux-wrapper/react";
import type { ViewModel } from "shared_types/types/shared_types";

import { router } from "@/App/router";

function renderWithCore(
  component: React.ReactNode,
  options?: { dispatch?: () => void; initialState?: ViewModel } = {},
) {
  const state = new State(initialState);
  const renderResult = render(
    <MockCoreProvider dispatch={dispatch} state={state}>
      {component}
    </MockCoreProvider>,
  );

  return {
    ...renderResult,
    updateViewModel: (payload: Partial<ViewModel>) =>
      state.setViewModel(payload as ViewModel),
  };
}

describe("Counter", () => {
  it("sends an increment event when clicking on the increment button", () => {
    const dispatch = jest.fn();
    const { getByText, updateViewModel } = renderWithCoreLogic(
      <MyComponent />,
      { dispatch },
    );

    getByText("Increment").click();

    expect(dispatch).toHaveBeenCalledWith(new EventVariantIncrement());
  });

  it("updates the counter when viewModel is updated", () => {
    const { getByText, updateViewModel } = renderWithCoreLogic(
      <MyComponent />,
      { initialState: { count: 0 } },
    );

    expect(getByText("Count: 0")).not.toBeNull();

    act(() => {
      updateViewModel({ count: 1 });
    });

    expect(getByText("Count: 1")).not.toBeNull();
  });
});

Logs

The crux-wrapper provides a way to log the events, effects and responses that are exchanged between your core and your shell. This can be useful for debugging and understanding the flow of your application. There are 2 ways to get logs from your crux app from react:

  • a reactive useLogs hook that will return the logs as an array of objects
  • a useGetLog hook that allows you to get the logs whenever you need it (without subscribing to changes and causing unecessary rerenderings)
// This will only print logs when the button is clicked (no rerenderings)
function LogPrinter() {
  const getLogs = useGetLogs(); // exposes a function that do not subscribe to log changes

  return (
    <div>
      <button onClick={() => console.log(getLogs())}>
        Print logs to console
    </div>
  );
}
// This will live print logs as they come in (rerenders on every log change)
function LiveLogs() {
  const logs = useLogs(); // will subscribe to log changes

  return (
    <div>
      <ul key={index}>
        {logs.map((log, index) => (
          <li>
            {log.at} {log.type} {log.name}
          </li>
        ))}
      </ul>
    </div>
  );
}