Understanding Component Runtime Environments

ed
eden-e1 year ago

Introduction

Runtime environments are normally mentioned in the context of programs or applications rather than components. However, the term can be extended to fit components as well. Runtime environments are the context that a component requires for it to function properly. The more specific that context is, the less compatible the component is with other components and applications.

It's useful to think of runtime environments as being composed of "onion layers", where the inner layers are dependent on the outer layers.

For example, think of a simple client-side React component. What is its runtime environment?

On the very outer layer, we have the JavaScript engine, then the browser (the browser API), and finally, the React and React DOM libraries.

We can ignore other layers, like the operating system and the CPU architecture, since they are less relevant to JavaScript and Web (although, not completely irrelevant).

Component compatibility based on runtime environments

In the example above, the React component requires a browser, a React library, and a React DOM library.

A component that requires some of these runtime layers, like a function that updates the browser local storage, would still be compatible with the client-side React component. Both components would be able to integrate into a single application, running as a single process. Furthermore, depending on the components' purpose and API, the two might even be interchangeable.

In contrast, a component that requires different runtime layers that are mutually exclusive, for instance, one that needs the NodeJS runtime to perform read/write operations in the filesystem, would not be able to integrate with the client-side React component into a single application, running as a single process. The two components would be incompatible, as they require a different context for their execution.

When building Bit components, we need to consider the runtime environment of the component; Define it, implement the component accordingly, and inform its potential consumers, so they would be able tell where and how to use it.

There are several ways to broaden the compatibility of a component, and we'll discuss some of them later in this post.

Using dev environments to define runtime environments

A component's runtime environment is defined via the component's development environment. Component development environments or 'envs', are absent when the component is executed in runtime since, as their name suggests, they are only used during development.

However, configuring the proper tools and settings in the component's development environment, ensures that a component is able to function in its target runtime environment. This is done on four levels.

1. Runtime environment-relevant feedback during development

Dev tools should be configured to provide relevant feedback during the component's development. For instance, if our component requires a browser, we would configure its env's Typescript compiler with the lib option set to "DOM", so our dev tools and IDE would know that the component is allowed to use the browser API.

Other examples include configuring the ESLint plugin to prevent the use of NodeJS-specific APIs, or configuring the Jest test runner to run the tests in a browser-like environment.

my-org.my-scope/my-env
config
config/eslintrc.js
eslintrc.js
config/jest.config.js
jest.config.js
config/tsconfig.json
tsconfig.json
index.ts
index.ts
my-env.bit-env.ts
my-env.bit-env.ts

Runtime agnostic components

In some case, components should be runtime-agnostic (i.e, depend only on a generic Javascript engine). One example of that are entity components which provide type definitions, schema validation, and utility functions for the manipulation of a certain type of data. They are often used as a "contract" between backend services and frontend applications. In this case, we want our dev tools to prevent us from using APIs that are not available in the target runtime environment.

See an example of a TS runtime agnostic env.

See an example of a generic entity. See an example of a Medium article submission entity (implemented with Zod)

2. Build artifacts that are compatible with the target runtime environment

The component build should generate artifacts that are compatible with the target runtime environment. For instance, if the runtime environment we're aiming for is a modern browser, we would set the target option to es6 or higher (see the tsconfig.json file in the demo env above).

3. Peer dependencies that make up the "inner layers" of the runtime environment

Peer dependencies are a crucial part of defining the component's runtime environment. A peer dependency is a dependency that is expected to be provided by the "host" or app consuming the component, rather than the component itself. Remember that, from the perspective of a component, parts of the consuming app can be considered as parts of its runtime environment. That happens when these parts function as the context that the component requires for it to function properly.

For example, a React 17 component will probably have the following dependencies configured in its env's env.jsonc file:

my-org.my-scope/my-env
env.jsonc
env.jsonc
index.ts
index.ts
my-env.bit-env.ts
my-env.bit-env.ts

Note that peer dependencies are not only for framework libraries, but also for other components and packages that are expected to be provided by the consuming app. This can be a theme, a routing system, a global state, or any other package that is expected to be provided by the consuming app.

4. Component previews that support the relevant runtime environment

Component previews are useful part of the component's development, testing, and documentation. A 'preview' dev service (configured in the component's env) should provide the same context that the component requires for it to function properly.

For instance, React components should use a preview that runs React for rendering. If they requires additional context, like a theme or a routing system, this should be provided as well.

my-org.my-scope/my-env
config
config/tsconfig.json
tsconfig.json
index.ts
index.ts
mounter.tsx
mounter.tsx
my-env.bit-env.ts
my-env.bit-env.ts

Declaring the runtime environment of a component

A component's runtime environment should be apparent to its consumers by looking at the component's env (development environment).

An env should have a component ID that informs of the runtime environment its components are compatible with. For instance, the component ID of an env for React 17 components, should be my-org.my-scope/react-17. The same is true for the env's icon and name properties.

/**
 * @filename: react-17-env.bit-env.ts
 * @component-id: my-org.my-scope/react-17
 * */

export class React17Env {
  name = 'react-17';
  icon = 'https://path/to/icon/react.svg';
}
CopiedCopy

The env icon, name and component ID are displayed in the UI, and are used by Bit's search engine to match components with their consumers.

Broadening the runtime environment of a component

The main topic for this blog post is not how to increase compatibility and reusability of components, but it's worth mentioning that there are several ways to do so.

1. Generic envs over specific envs

The more generic the env is, the broader the range of runtime environments its components are compatible with. For instance, setting your client-side component with a generic env, like the HTML env (when possible) will make it clear to its consumers that it is compatible with a wide range of frontend applications, and is not limited to a specific framework or runtime environment.

2. Fallbacks

In some cases, it makes sense for a component to have fallbacks for more generic runtime environments. For instance, a NextJS component that requires the NextJS runtime, can have a fallback which would allow it to be used in a wide range of React applications.

In the example below, a NextJS component uses the NextJS image component only if it's available in the runtime environment.

/* @filename: {COMPONENT_DIRECTORY}/card.tsx */

/**
 * This component utilizes the image optimization feature, provided by NextJS.
 */
import Image from 'next/image';

export function Card({ info }) {
  const isNextJS = process.env.FRAMEWORK === 'nextjs';
  return (
    <article key={info.id}>
      <h5>{info.name}</h5>
      <Image
        src={info.image}
        alt={info.name}
        fill
        /* disable NextJS image optimization in non-NextJS React environments */
        unoptimized={!isNextJS}
      />
    </article>
  );
}
CopiedCopy

3. Dependency injection for runtime-specific APIs

Using dependency injection a component can implement a "placeholder" for a runtime-specific API, that allows the consuming app to provide the actual implementation.

For example, a React component might use a different routing system, based on the app that consumes it. In this case, the Link component needs to implement a different logic that would use the routing system provided by the consuming app.

For an example, see this React Router Adapter

4. Multiple build outputs

A component can have multiple build outputs, each targeting a different runtime environment. For example, the following env has in its build pipeline two compilation steps, one for modern browsers and one for legacy browsers.

my-org.my-scope/my-env
config
config/tsconfig.legacy.json
tsconfig.legacy.json
config/tsconfig.modern.json
tsconfig.modern.json
index.ts
index.ts
my-env.bit-env.ts
my-env.bit-env.ts