Register deferred navigation items

Navigation items cannot always be registered before the application bootstrapping process, as some of them depend on remote data and/or feature flags.

To address this, Squide offers an alternate deferred registration mechanism in two-phases:

  1. The first phase allows modules to register their navigation items that are not dependent on remote data or feature flags.
  2. The second phase enables modules to register deferred navigation items that are dependent on remote data and/or feature flags by returning a function. We refer to this second phase as deferred registrations.

For more details, refer to the initializeFirefly and useDeferredRegistrations reference documentation.

Register a deferred item

To defer a registration to the second phase, a module's registration function can return an anonymous function matching the DeferredRegistrationFunction type: (data, operation: "register" | "update") => Promise | void.

Remote data

The returned registration function can conditionally register navigation items based on the remote data passed as its second argument:

import type { ModuleRegisterFunction, FireflyRuntime } from "@squide/firefly";
import type { DeferredRegistrationData } from "@sample/shared";

export const register: ModuleRegisterFunction<FireflyRuntime, unknown, DeferredRegistrationData> = runtime => {
    // Once the user data has been loaded by the host application, by completing the module registrations process,
    // the deferred registration function will be called with the user data.
    return (deferredRuntime, { userData }) => {
        // Only register the "feature-a" route and navigation item if the user is an administrator.
        if (userData.isAdmin) {
            deferredRuntime.registerNavigationItem({
                $id: "feature-a",
                $label: "Feature A",
                to: "/feature-a"
            });
        }
    };
};

Feature flags

And/or based on a LaunchDarkly feature flag:

import type { ModuleRegisterFunction, FireflyRuntime } from "@squide/firefly";
import type { DeferredRegistrationData } from "@sample/shared";

export const register: ModuleRegisterFunction<FireflyRuntime, unknown, DeferredRegistrationData> = runtime => {
    // Once the user data has been loaded by the host application, by completing the module registrations process,
    // the deferred registration function will be called with the user data.
    return (deferredRuntime) => {
        // Only register the "feature-a" route and navigation item if "feature-a" flag is activated.
        if (deferredRuntime.getFeatureFlag("enable-feature-a")) {
            deferredRuntime.registerNavigationItem({
                $id: "feature-a",
                $label: "Feature A",
                to: "/feature-a"
            });
        }
    };
};
export interface UserInfo {
    isAdmin: boolean;
}

export interface DeferredRegistrationData {
    userInfo?: UserInfo;
}

Execute the deferred registrations

It's the responsibility of the application shell to execute deferred registrations. If deferred registrations depend on remote data, register them once that data has been retrieved:

import { AppRouter, useIsBootstrapping, usePublicDataQueries, useDeferredRegistrations } from "@squide/firefly";
import { createBrowserRouter, Outlet } from "react-router";
import { RouterProvider } from "react-router/dom";
import { DeferredRegistrationData, UserInfo } from "@sample/shared";

function BootstrappingRoute() {
    const [userInfo] = usePublicDataQueries([
        {
            queryKey: ["/api/user-info"],
            queryFn: async () => {
                const response = await fetch("/api/user-info");
                const data = await response.json();

                const userInfo: UserInfo = {
                    isAdmin: data.isAdmin
                };

                return userInfo;
            }
        }
    ]);

    // The useMemo hook is super important otherwise the hook will consider that the user info
    // object changed everytime the hook is rendered.
    const data: DeferredRegistrationData = useMemo(() => ({ 
        userInfo 
    }), [userInfo]);

    useDeferredRegistrations(data);

    if (useIsBootstrapping()) {
        return <div>Loading...</div>;
    }

    return <Outlet />;
}

export function App() {
    return (
        <AppRouter waitForPublicData>
            {({ rootRoute, registeredRoutes, routerProps, routerProviderProps }) => {
                return (
                    <RouterProvider
                        router={createBrowserRouter([
                            {
                                element: rootRoute,
                                children: [
                                    {
                                        element: <BootstrappingRoute />,
                                        children: registeredRoutes
                                    }
                                ]
                            }
                        ], routerProps)}
                        {...routerProviderProps}
                    />
                );
            }}
        </AppRouter>
    );
}

Otherwise, the deferred registrations can be registered without providing a data object:

import { AppRouter, useIsBootstrapping, useDeferredRegistrations } from "@squide/firefly";
import { createBrowserRouter, Outlet } from "react-router";
import { RouterProvider } from "react-router/dom";

function BootstrappingRoute() {
    useDeferredRegistrations();

    if (useIsBootstrapping()) {
        return <div>Loading...</div>;
    }

    return <Outlet />;
}

export function App() {
    return (
        <AppRouter>
            {({ rootRoute, registeredRoutes, routerProps, routerProviderProps }) => {
                return (
                    <RouterProvider
                        router={createBrowserRouter([
                            {
                                element: rootRoute,
                                children: [
                                    {
                                        element: <BootstrappingRoute />,
                                        children: registeredRoutes
                                    }
                                ]
                            }
                        ], routerProps)}
                        {...routerProviderProps}
                    />
                );
            }}
        </AppRouter>
    );
}

Update deferred items

Since Squide integrates with TanStack Query and LaunchDarkly feature flags, and both regularly get fresh data from the server, the remote data or feature flags on which deferred navigation items depend may change over time. When this happens, the deferred navigation items must be updated to reflect the current state of the application. For example, a user could be promoted from a regular user to an administrator and should then see additional navigation items. Similarly, a feature flag might enable or disable a feature, which would require navigation items to be added or removed accordingly.

Remote data updates

By using the useDeferredRegistrations hook in combination with TanStack Query, deferred registrations are automatically updated whenever a fresh remote data object is forwarded to useDeferredRegistrations:

import { useIsBootstrapping, useDeferredRegistrations, usePublicDataQueries } from "@squide/firefly";
import { Outlet } from "react-router";
import { DeferredRegistrationData, UserInfo } from "@sample/shared";

function BootstrappingRoute() {
    const [userInfo] = usePublicDataQueries([
        {
            queryKey: ["/api/user-info"],
            queryFn: async () => {
                const response = await fetch("/api/user-info");
                const data = await response.json();

                const userInfo: UserInfo = {
                    isAdmin: data.isAdmin
                };

                return userInfo;
            }
        }
    ]);

    // The useMemo hook is super important otherwise the hook will consider that the user info
    // object changed everytime the hook is rendered.
    const data: DeferredRegistrationData = useMemo(() => ({ 
        userInfo 
    }), [userInfo]);

    useDeferredRegistrations(data);

    if (useIsBootstrapping()) {
        return <div>Loading...</div>;
    }

    return <Outlet />;
}

Feature flag updates

However, if conditional navigation items only depend on feature flags, the useDeferredRegistrations hook can be balled without a data object. Deferred registrations will still be updated automatically whenever a feature flag value changes:

import { useIsBootstrapping, useDeferredRegistrations } from "@squide/firefly";
import { Outlet } from "react-router";

function BootstrappingRoute() {
    useDeferredRegistrations();

    if (useIsBootstrapping()) {
        return <div>Loading...</div>;
    }

    return <Outlet />;
}

Deferred registrations run again on every update

A deferred registration function is not executed once. It runs again every time the deferred registrations are updated, whenever a feature flag value changes or the data passed to useDeferredRegistrations changes.

Squide discards everything the previous run registered before replaying the new one. Every run must therefore register the full set of navigation items it wants rendered, not the difference since the last run:

import type { ModuleRegisterFunction, FireflyRuntime } from "@squide/firefly";

export const register: ModuleRegisterFunction<FireflyRuntime> = () => {
    return (deferredRuntime) => {
        if (deferredRuntime.getFeatureFlag("enable-feature-a")) {
            deferredRuntime.registerNavigationItem({
                $id: "feature-a",
                $label: "Feature A",
                to: "/feature-a"
            });
        }
    };
};

Squide only discards what it owns. A plugin exposing its own registry to modules must clear the entries of the previous run itself, by implementing the onDeferredRegistrationScopeStarted hook.

Missing sections are reported

When a nested navigation item is registered with a sectionId that no registered section matches, the item is held as a pending registration and is not rendered. Squide reports the sections that are still missing once the modules are ready. In development the report throws, in production it is logged.

This surfaces a sectionId that no module registers at bootstrap. It does not cover deferred registration update runs — the validation runs only once, when the modules become ready, so a section dropped by a later update run goes unreported until a page reload. Set strictMode to false on AppRouter to turn the validation off.