createDeferredRegistrationsRunner
Create a runner executing deferred registration functions through the same sequence as a real application (strictly for testing purpose).
This function is for tests only. To update the deferred registrations of a running application, use the useDeferredRegistrations hook. A runner doesn't notify React that the registrations changed, therefore an application driven by a runner would render stale navigation items.
Reference
const runner = createDeferredRegistrationsRunner(runtime, localModules: [], options?: { context? })
Parameters
runtime: A FireflyRuntime instance.localModules: An array of module registration functions.options: An optional object literal of options:context: An optional context object forwarded to the module registration functions.
Returns
An object literal with the following functions:
register(data?): Register the modules, then execute their deferred registration functions with the"register"operation. Can only be called once. Returns aPromiseobject resolving to an array ofModuleRegistrationError.update(data?): Execute the deferred registration functions again with the"update"operation, as a transactional run. Must be called afterregister. Returns aPromiseobject resolving to an array ofModuleRegistrationError.
An update run drops the deferred navigation items of the previous run and replays only what the current run registers. It also reproduces everything the useDeferredRegistrations hook does around that run, because modules, plugins and third-party libraries rely on it to reset their per run state:
DeferredRegistrationsUpdateStartedEventis dispatched.- The deferred registration functions are executed.
- The app router store
deferredRegistrationsUpdatedAtvalue is updated andDeferredRegistrationsUpdatedEventis dispatched. DeferredRegistrationsUpdateCompletedEventis dispatched.
Usage
Create a runner
import { createDeferredRegistrationsRunner } from "@squide/firefly/testing";
import { EnvironmentVariablesPlugin, FireflyRuntime, type ModuleRegisterFunction } from "@squide/firefly";
const register: ModuleRegisterFunction<FireflyRuntime, unknown, FeatureFlags> = runtime => {
return (deferredRuntime, data) => {
if (data.isBillingEnabled) {
deferredRuntime.registerNavigationItem({ $id: "billing", $label: "Billing", to: "/billing" });
}
};
};
const runtime = new FireflyRuntime({
plugins: [x => new EnvironmentVariablesPlugin(x)]
});
const runner = createDeferredRegistrationsRunner(runtime, [register]);
A runner takes a runtime rather than creating one, and initializeFirefly cannot be used in tests because it can only be executed once per process. Construct the runtime with the plugins the modules under test depend on: initializeFirefly always registers an EnvironmentVariablesPlugin, so a module calling registerEnvironmentVariable or getEnvironmentVariable fails against a plugin less runtime.
Execute a registration run
await runner.register({ isBillingEnabled: true });
expect(runtime.getNavigationItems().length).toBe(1);
Execute an update run
An update run reproduces what happens when a feature flag or the session changes:
await runner.register({ isBillingEnabled: true });
await runner.update({ isBillingEnabled: false });
expect(runtime.getNavigationItems().length).toBe(0);
Test multiple modules
Deferred registration defects usually involve more than one module. Provide every module participating in the scenario, as they all execute within the same run:
const registerSection: ModuleRegisterFunction<FireflyRuntime, unknown, FeatureFlags> = () => {
return (runtime, data) => {
if (data.isBillingEnabled) {
runtime.registerNavigationItem({ $id: "billing", $label: "Billing", children: [] });
}
};
};
const registerNestedItem: ModuleRegisterFunction<FireflyRuntime, unknown, FeatureFlags> = () => {
return runtime => {
runtime.registerNavigationItem({ $id: "invoices", $label: "Invoices", to: "/invoices" }, { sectionId: "billing" });
};
};
const runner = createDeferredRegistrationsRunner(runtime, [registerSection, registerNestedItem]);
Test a module reacting to an update run
A module keeping state across its registrations usually resets that state when an update run starts. Since a runner dispatches the update events, the module reacts exactly as it would at runtime:
import { DeferredRegistrationsUpdateStartedEvent } from "@squide/firefly";
const register: ModuleRegisterFunction<FireflyRuntime, unknown, FeatureFlags> = runtime => {
const registeredSections = new Set<string>();
runtime.eventBus.addListener(DeferredRegistrationsUpdateStartedEvent, () => registeredSections.clear());
return (deferredRuntime, data) => {
if (!data.isBillingEnabled) {
return;
}
// Register the section on the first item of the run.
if (!registeredSections.has("billing")) {
registeredSections.add("billing");
deferredRuntime.registerNavigationItem({ $id: "billing", $label: "Billing", children: [] });
}
deferredRuntime.registerNavigationItem({ $id: "invoices", $label: "Invoices", to: "/invoices" }, { sectionId: "billing" });
};
};
const runner = createDeferredRegistrationsRunner(runtime, [register]);
await runner.register({ isBillingEnabled: true });
await runner.update({ isBillingEnabled: true });
expect(runtime.getNavigationItems().length).toBe(1);
Without the listener, the section is registered on the first run only, and the update run leaves the items pending under a section that no longer exists.
A runner dispatches the update events itself, standing in for the useDeferredRegistrations hook. Such a test asserts that a module reacts correctly to those events, not that they are dispatched at runtime. Squide covers that half.
Handle registration errors
Errors are collected rather than thrown, matching what a real registration run does:
const errors = await runner.register({ isBillingEnabled: true });
expect(errors.length).toBe(0);
Test a standalone deferred registration function
When a test targets a deferred registration function rather than a module, wrap the function into a module registration function:
const registerBillingNavigationItems: DeferredRegistrationFunction<FireflyRuntime, FeatureFlags> = (runtime, data) => {
// ...
};
const runner = createDeferredRegistrationsRunner(runtime, [() => registerBillingNavigationItems]);
Provide a context
const runner = createDeferredRegistrationsRunner(runtime, [register], {
context: { host: "sample" }
});
Assert that no registration is pending
Navigation items registered under a section that no longer exists are parked as pending rather than rejected, so a run that lost a section still resolves without errors. Assert on the resulting navigation items to catch it:
await runner.register({ isBillingEnabled: true });
await runner.update({ isBillingEnabled: true });
expect(runtime.getNavigationItems()).toMatchObject([{ $id: "billing", children: [{ $id: "invoices" }] }]);
Prefer asserting on the navigation items over runtime._validateRegistrations(). That function validates routes before navigation items, and routes registered without an explicit parent default to the PublicRoutes and ProtectedRoutes outlets, which are registered by the application's router rather than by a runner. Against a headless runtime it therefore throws The ProtectedRoutes outlet is missing from the router configuration for any module registering a route, whatever the state of the navigation items.