How to override components?

Longer, detailed and not finished version

Milan Herda, 07/2024

The Problem

1. Design based on

the current portal

2. Different components for the same functionality

3. Different set of features

Let's start with an obvious approach

'use client';

import HeroBannerPrace from '@prace/ui/HeroBanner';
import HeroBannerJobs from '@jobs/ui/Homepage/HeroBanner';
import DefaultHeroBanner from '@local/ui/hero/HeroBanner';
import SearchForm from '@local/ui/search/SearchForm';
import useHostname from '@local/router/infrastructure/react/useHostname';

export default function Homepage() {
    const hostname = useHostname();
  
    let HeroBanner = DefaultHeroBanner;
  
    if (hostname === 'prace.cz') {
        HeroBanner = HeroBannerPrace;
    } else if (hostname === 'jobs.cz') {
        HeroBanner = HeroBannerJobs;
    }
 
    return (
        <>
            <HeroBanner />
            <SearchForm />
            <>
        </>
    );
}

SOLID principles to the rescue

Single Responsibility Principle

'use client';

import HeroBannerPrace from '@prace/ui/HeroBanner';
import HeroBannerJobs from '@jobs/ui/Homepage/HeroBanner';
import DefaultHeroBanner from '@local/ui/hero/HeroBanner';
import SearchForm from '@local/ui/search/SearchForm';
import useHostname from '@local/router/infrastructure/react/useHostname';

export default function Homepage() {
    const hostname = useHostname();
  
    let HeroBanner = DefaultHeroBanner;
  
    if (hostname === 'prace.cz') {
        HeroBanner = HeroBannerPrace;
    } else if (hostname === 'jobs.cz') {
        HeroBanner = HeroBannerJobs;
    }
 
    return (
        <>
            <HeroBanner />
            <SearchForm />
            <>
        </>
    );
}

Single Responsibility Principle

'use client';

import HeroBannerPrace from '@prace/ui/HeroBanner';
import HeroBannerJobs from '@jobs/ui/Homepage/HeroBanner';
import DefaultHeroBanner from '@local/ui/hero/HeroBanner';
import SearchForm from '@local/ui/search/SearchForm';
import useHostname from '@local/router/infrastructure/react/useHostname';

export default function Homepage() {
    const hostname = useHostname();
  
    const HeroBanner = resolveHeroBanner(hostname)
  
    return (
        <>
            <HeroBanner />
            <SearchForm />
            <>
        </>
    );
}

function resolveHeroBanner(hostname: string, componentName: string) {
    let HeroBanner = DefaultHeroBanner;
  
    if (hostname === 'prace.cz') {
        HeroBanner = HeroBannerPrace;
    } else if (hostname === 'jobs.cz') {
        HeroBanner = HeroBannerJobs;
    }
 
    return HeroBanner;
}

Single Responsibility Principle

'use client';

import SearchForm from '@local/ui/search/SearchForm';
import useHostname from '@local/router/infrastructure/react/useHostname';
import resolveHeroBanner from '@local/resolver/application/resolveBanner';

export default function Homepage() {
    const hostname = useHostname();
  
    const HeroBanner = resolveHeroBanner(hostname)
  
    return (
        <>
            <HeroBanner />
            <SearchForm />
        </>
    );
}

Single Responsibility Principle

import HeroBannerPrace from '@prace/ui/HeroBanner';
import HeroBannerJobs from '@jobs/ui/Homepage/HeroBanner';
import DefaultHeroBanner from '@local/ui/hero/HeroBanner';

export default function resolveHeroBanner(hostname: string) {
    let HeroBanner = DefaultHeroBanner;
  
    if (hostname === 'prace.cz') {
        HeroBanner = HeroBannerPrace;
    } else if (hostname === 'jobs.cz') {
        HeroBanner = HeroBannerJobs;
    }
 
    return HeroBanner;
}

Open-Closed Principle

import HeroBannerPrace from '@prace/ui/HeroBanner';
import HeroBannerJobs from '@jobs/ui/Homepage/HeroBanner';
import DefaultHeroBanner from '@local/ui/hero/HeroBanner';

export default function resolveHeroBanner(hostname: string) {
    let HeroBanner = DefaultHeroBanner;
  
    if (hostname === 'prace.cz') {
        HeroBanner = HeroBannerPrace;
    } else if (hostname === 'jobs.cz') {
        HeroBanner = HeroBannerJobs;
    }
 
    return HeroBanner;
}

Open-Closed Principle

import HeroBannerPrace from '@prace/ui/HeroBanner';
import HeroBannerJobs from '@jobs/ui/Homepage/HeroBanner';
import DefaultHeroBanner from '@local/ui/hero/HeroBanner';

const componentsRegistry = {
    'prace.cz': HeroBannerPrace,
    'jobs.cz': HeroBannerJobs,
};

export default function resolveHeroBanner(hostname: string) {
    if (componentsRegistry[hostname]) {
        return componentsRegistry[hostname];
    }
  
    return DefaultHeroBanner;
}

Open-Closed Principle

import HeroBannerPrace from '@prace/ui/HeroBanner';
import HeroBannerJobs from '@jobs/ui/Homepage/HeroBanner';
import DefaultHeroBanner from '@local/ui/hero/HeroBanner';

const componentsRegistry = {
    'default': DefaultHeroBanner,
    'prace.cz': HeroBannerPrace,
    'jobs.cz': HeroBannerJobs,
};

export default function resolveHeroBanner(hostname: string) {
    if (componentsRegistry[hostname]) {
        return componentsRegistry[hostname];
    }
  
    return componentsRegistry.default ?? () => null;
}

Open-Closed Principle

import HeroBannerPrace from '@prace/ui/HeroBanner';
import HeroBannerJobs from '@jobs/ui/Homepage/HeroBanner';
import DefaultHeroBanner from '@local/ui/hero/HeroBanner';

const componentsRegistry = {
    'default': DefaultHeroBanner,
    'prace.cz': HeroBannerPrace,
    'jobs.cz': HeroBannerJobs,
};

export default function resolveComponent(hostname: string, componentName: string) {
    if (componentsRegistry[hostname]) {
        return componentsRegistry[hostname];
    }
  
    return componentsRegistry.default ?? () => null;
}

Open-Closed Principle

import HeroBannerPrace from '@prace/ui/HeroBanner';
import HeroBannerJobs from '@jobs/ui/Homepage/HeroBanner';
import DefaultHeroBanner from '@local/ui/hero/HeroBanner';

const componentsRegistry = {
    'default': {
        HeroBanner: DefaultHeroBanner,
    },
    'prace.cz': {
        HeroBanner: HeroBannerPrace,
    },
    'jobs.cz': {
        HeroBanner: HeroBannerJobs,
    },
};

export default function resolveComponent(hostname: string, componentName: string) {
    if (componentsRegistry[hostname]?.[componentName]) {
        return componentsRegistry[hostname][componentName];
    }
  
    return componentsRegistry.default[componentName] ?? () => null;
}

Open-Closed Principle

import componentsRegistry from '@local/configuration/componentsRegistry';

export default function resolveComponent(hostname: string, componentName: string) {
    if (componentsRegistry[hostname]?.[componentName]) {
        return componentsRegistry[hostname][componentName];
    }
  
    return componentsRegistry.default[componentName] ?? () => null;
}

What about different non-component services per hostname?

Renaming to the rescue!

import componentsRegistry from '@local/configuration/componentsRegistry';

export default function resolveComponent(hostname: string, componentName: string) {
    if (componentsRegistry[hostname]?.[componentName]) {
        return componentsRegistry[hostname][componentName];
    }
  
    return componentsRegistry.default[componentName] ?? () => null;
}
import serviceRegistry from '@local/configuration/serviceRegistry';

export default function resolveService(hostname: string, serviceName: string) {
    if (serviceRegistry[hostname]?.[serviceName]) {
        return serviceRegistry[hostname][serviceName];
    }
  
    return serviceRegistry.default[serviceName] ?? () => null;
}

⬇️

ServiceRegistry

// src/configuration/serviceRegistry
import HeroBannerPrace from '@prace/ui/HeroBanner';
import HeroBannerJobs from '@jobs/ui/Homepage/HeroBanner';
import DefaultHeroBanner from '@local/ui/hero/HeroBanner';
import JobOfferRepository from '@search/jobs/repository/JobOfferRepository';

const serviceRegistry = {
    'default': {
        HeroBanner: DefaultHeroBanner,
        jobOfferRepository: JobOfferRepository,
    },
    'prace.cz': {
        HeroBanner: HeroBannerPrace,
    },
    'jobs.cz': {
        HeroBanner: HeroBannerJobs,
    },
};

export default serviceRegistry;

What about different parameters per hostname?

ServiceRegistry => Registry

// src/configuration/serviceRegistry
import HeroBannerPrace from '@prace/ui/HeroBanner';
import HeroBannerJobs from '@jobs/ui/Homepage/HeroBanner';
import DefaultHeroBanner from '@local/ui/hero/HeroBanner';
import jobOfferRepository from '@search/jobs/repository/jobOfferRepository';

const serviceRegistry = {
    'default': {
        HeroBanner: DefaultHeroBanner,
        jobOfferRepository: jobOfferRepository,
    },
    'prace.cz': {
        HeroBanner: HeroBannerPrace,
    },
    'jobs.cz': {
        HeroBanner: HeroBannerJobs,
    },
};

export default serviceRegistry;

ServiceRegistry => Registry

// src/configuration/serviceRegistry
import HeroBannerPrace from '@prace/ui/HeroBanner';
import HeroBannerJobs from '@jobs/ui/Homepage/HeroBanner';
import DefaultHeroBanner from '@local/ui/hero/HeroBanner';
import jobOfferRepository from '@search/jobs/repository/jobOfferRepository';

const registry = {
    services: {
        'default': {
            HeroBanner: DefaultHeroBanner,
            jobOfferRepository: jobOfferRepository,
        },
        'prace.cz': {
            HeroBanner: HeroBannerPrace,
        },
        'jobs.cz': {
            HeroBanner: HeroBannerJobs,
        },
    }
};

export default registry;

ServiceRegistry => Registry

// src/configuration/registry
import HeroBannerPrace from '@prace/ui/HeroBanner';
import HeroBannerJobs from '@jobs/ui/Homepage/HeroBanner';
import DefaultHeroBanner from '@local/ui/hero/HeroBanner';
import jobOfferRepository from '@search/jobs/repository/jobOfferRepository';

const registry = {
    services: {
        'default': {
            HeroBanner: DefaultHeroBanner,
            jobOfferRepository: jobOfferRepository,
        },
        'prace.cz': {
            HeroBanner: HeroBannerPrace,
        },
        'jobs.cz': {
            HeroBanner: HeroBannerJobs,
        },
    },
    parameters: {
        'default': {
			searchEngine: 'job-matching',
        },
        'prace.cz': {
            searchEngine: 'algolia',
        },
        'jobs.cz': {
            searchEngine: 'elastic'
        },
    },
};

export default registry;

ServiceRegistry => Registry

const registry = {
    components: {
        'default': {
            HeroBanner: DefaultHeroBanner,
        },
        'prace.cz': {
            HeroBanner: HeroBannerPrace,
        },
        'jobs.cz': {
            HeroBanner: HeroBannerJobs,
        },
    },
    services: {
        'default': {
            jobOfferRepository: jobOfferRepository,
        },
    },
    parameters: {
        'default': {
			searchEngine: 'job-matching',
        },
        'prace.cz': {
            searchEngine: 'algolia',
        },
        'jobs.cz': {
            searchEngine: 'elastic'
        },
    },
};

export default registry;

"Resolvers"

import registry from '@local/configuration/registry';

export function resolveComponent(hostname: string, componentName: string) {
    const { components } = registry;
  
    if (components[hostname]?.[componentName]) {
        return components[hostname][componentName];
    }
  
    return components.default?.[componentName] ?? () => null;
}

export function resolveService(hostname: string, serviceName: string) {
    const { services } = registry;
  
    if (services[hostname]?.[serviceName]) {
        return services[hostname][serviceName];
    }
  
    return services.default?.[serviceName];
}

export function getParameter(hostname: string, name: string) {
    const { parameters } = registry;
  
    if (parameters[hostname]?.[name]) {
        return parameters[hostname][name];
    }
  
    return parameters.default?.[name];
}

More issues to solve

  • we do not have working types on resolved components
  • configuration file imports all components and services for the whole website and every domain
  • how do we create a configuration file?
  • what if we include a React Server Component into the configuration and use it from client component?
  • how to use it from other packages?

Working TypeScript types

Working types: Naive approach

  • we will introduce a requirement on developers to provide named types/interfaces on each overridable component/service
  • we use this type when obtaining service from the registry

Working types: Naive approach

export function resolveComponent<T>(hostname: string, componentName: string): T {
    // ...
}

export function resolveService<T>(hostname: string, serviceName: string): T {
    // ...
}
'use client';

import SearchForm from '@local/ui/search/SearchForm';
import useHostname from '@local/router/infrastructure/react/useHostname';
import { ComponentType } from 'react';
import type { HeroBannerProps } from '@local/ui/homepage/HeroBanner';

export default function Homepage() {
    const hostname = useHostname();
  
    const HeroBanner = resolveComponent<ComponentType<HeroBannerProps>>(hostname, 'HeroBanner')
  
    return (
        <>
            <HeroBanner title="The Best Job Offers!" />
            <SearchForm />
        </>
    );
}

Working types: 100% correct approach

  • parse configuration
  • extract types from services
  • build a mapping of a service name to the correct type

We will have to generate types

in a build step

All components

& services are imported in configuration

Why is this a problem?

  • client-side bundle will contain a code user will not need
    • services for pages not visited
    • even services for another websites
  • bundle size will be too big

Split configuration to hostname-specific files

// /src/configuration/registry/prace

import HeroBanner from '@prace/ui/HeroBanner';
import jobOfferRepository from '@search/jobs/repository/jobOfferRepository';

const registry = {
    components: {
        HeroBanner: HeroBanner,
    },
    services: {
        jobOfferRepository: jobOfferRepository,
    },
    parameters: {
        searchEngine: 'algolia',
    },
};

export default registry;

But what about resolvers?

// /src/configuration/resolver

export async function resolveComponent(hostname: string, componentName: string) {
	const registryModule = await import(`./registry/${hostname}`);
    const registry = registryModule.default;
  
    const { components } = registry;  

	if (components?.[componentName]) {
		return components[componentName];
	}

	return () => null;
}

We can dynamically import only the required one

  • ✅ one deployed instance can serve multiple domains

But what about resolvers?

// /src/configuration/resolver

import registry from '@local/configuration/registry'

export function resolveComponent(componentName: string) {
	const { components } = registry;

	if (components?.[componentName]) {
		return components[componentName];
	}

	return () => null;
}

Or we can build only one registry during the deployment

and it will be tied to the deployed hostname-instance

  • ✅ simpler programming
  • ❌ one deployed instance can serve only one domain

We will have to generate registers

in a build step

The registry is still too large, can we ... maybe ... lazy load some components and services?

Lazy loading of services

// /src/configuration/registry/prace

import HeroBanner from '@prace/ui/HeroBanner';
import jobOfferRepository from '@search/jobs/repository/jobOfferRepository';

const registry = {
    components: {
        HeroBanner: HeroBanner,
        SearchForm: '@local/ui/SearchForm',
    },
    services: {
        jobOfferRepository: jobOfferRepository,
    },
    parameters: {
        searchEngine: 'algolia',
    },
};

export default registry;

Lazy loading of services

// /src/configuration/resolver

import { ComponentType } from 'react';
import dynamic from 'next/dynamic'
import registry from '@local/configuration/registry'

function DefaultSkeleton() {
    return null;
    //return <p>Loading...</p>;
}

export function resolveComponent(
    componentName: string,
    skeleton: ComponentType = DefaultSkeleton
) {
	const { components } = registry;

	if (components?.[componentName]) {
        if (typeof components[componentName] === 'string') {
            return dynamic(() => import(components[componentName]), {
                loading: skeleton,
            });
        }
      
		return components[componentName];
	}

	return () => null;
}

Lazy loading of services

return dynamic(() => import(components[componentName]), {
    loading: skeleton,
});

We can not use only variables in dynamic imports.

We need to help bundler to know what files can be loaded

But they can be placed anywhere...

Lazy loading of services

return dynamic(
    () => {
  	    return import(`@local/ui/agreed-place-1/${componentName}`)
            .catch(
                () => import(`@search/ui/agreed-place-2/${componentName}`)
            );
    }, {
       loading: skeleton,
    }
);

We can either restrict programmers and tell them they need to place their components in a few agreed locations

Lazy loading of services

// /src/configuration/resolver

import { ComponentType } from 'react';
import dynamic from 'next/dynamic'
import registry from '@local/configuration/registry'

function DefaultSkeleton() {
    return null;
    //return <p>Loading...</p>;
}

export function resolveComponent(
    componentName: string,
    skeleton: ComponentType = DefaultSkeleton
) {
	const { components } = registry;

	if (componentName === 'SearchForm') {
        return dynamic(() => import('@local/ui/SearchForm'), {
            loading: skeleton,
        });
    }
  
  	return components[componentName] ?? () => null;
}

Or we will have to generate resolvers

in a build step

  • types
  • registers
  • resolvers

 

What do we need to generate?

To be able to do so, we need some form of

"recipe"

configuration

"recipe"

Configuration in JSON files

// /src/configuration/registry/prace

import HeroBanner from '@prace/ui/HeroBanner';
import jobOfferRepository from '@search/jobs/repository/jobOfferRepository';

const registry = {
    components: {
        HeroBanner: HeroBanner,
        SearchForm: '@local/ui/SearchForm',
    },
    services: {
        jobOfferRepository: jobOfferRepository,
    },
    parameters: {
        searchEngine: 'algolia',
    },
};

export default registry;

Configuration in JSON files

// /src/config/prace/config.json

{
    "components": {
        "HeroBanner": "@prace/ui/HeroBanner",
        "SearchForm": [
            "@local/ui/SearchForm",
            {
                "isLazy": true
            }
        ]
    },
    "services": {
        "jobOfferRepository": "@search/jobs/repository/jobOfferRepository"
    },
    "parameters": {
        "searchEngine": "algolia"
    }
}

Configuration in JSON files

  • Configuration for one domain can be combined from default configuration file and configuration file with overrides for that domain
  • We can even split configuration into multiple files and have one folder for "default" configuration and another folder for "domain" configuration.

React Server Components

can not be used inside of client components

React Server Components

  • even their import in registry will break the build of our application
    • this is actually a good thing
  • we need to provide two registries
    • one for server part
      • with all services
    • one for client part
      • with only client-side services

React Server Components

// /src/config/prace/config.json

{
    "components": {
        "HeroBanner": [
            "@prace/ui/HeroBanner",
            {
                "isServerSide": true,
            }
        ],
        "SearchForm": [
            "@local/ui/SearchForm",
            {
                "isLazy": true
            }
        ]
    },
    "services": {
        "jobOfferRepository": "@search/jobs/repository/jobOfferRepository"
    },
    "parameters": {
        "searchEngine": "algolia"
    }
}

React Server Components

// /src/config/prace/config.json

{
    "components": {
        "HeroBanner": [
            "@prace/ui/HeroBanner",
            {
                "isServerSide": true,
            }
        ],
        "SearchForm": [
            "@local/ui/SearchForm",
            {
                "isLazy": true
            }
        ]
    },
    "services": {
        "jobOfferRepository": "@search/jobs/repository/jobOfferRepository"
    },
    "parameters": {
        "searchEngine": "algolia"
    }
}

How to use resolver from application and also from libraries?

App with register & resolver

Library

App with register

Library

Resolver

Client components

// libs/search/src/ui/Listing/Item.tsx

'use client'

import { useContext } from 'react';
import ServiceContainerContext from '@container/infrastructure/react/ServiceContainerContext';

export default function Item() {
    const { resolveComponent } = useContext(ServiceContainerContext);
  
    const OfferLabel = resolveComponent('Listing/OfferLabel');
  
    return (
        <>
            {/*...*/}
            <OfferLabel />
            {/*...*/}
        </>
    );
}

Server components

// libs/search/src/ui/Listing/Item.tsx

import container from '@container/container/infrastructure/server';

export default function Item() {
    const OfferLabel = container.resolveComponent('Listing/OfferLabel');
  
    return (
        <>
            {/*...*/}
            <OfferLabel />
            {/*...*/}
        </>
    );
}

Server components

// libs/container/src/container/infrastructure/server.ts

import 'server-only';
import createContainer from '@local/container/application/createContainer';
import DependencyInjectionContainer, {
    DependencyInjectionContainerProxy
} from '@local/container/application/types';

export function createProxyContainer(): DependencyInjectionContainerProxy {
	let wrappedContainer: DependencyInjectionContainer = createContainer();

	return {
		setService(serviceName, service) {
			wrappedContainer.setService(serviceName, service);

			return this;
		},
		getService(serviceName) {
			return wrappedContainer.getService(serviceName);
		},
		wrapContainer(container) {
			wrappedContainer = container;

			return this;
		},
	};
}

const container: DependencyInjectionContainerProxy = createProxyContainer();

export default container;

Problem with this solution:

  • proxy container is effectively a singleton
  • singletons are problem on a JS server, because of it single-threaded nature
    • while we are processing one request, another one can come and replace the real container inside a proxy
    • since memory is shared, it will be replaced for all currently running requests
    • it will be a disaster if application would serve multiple domains

How to override components: Detailed version

By Milan Herda

How to override components: Detailed version

  • 103