Skip to content

Commit

Permalink
Support registering graphs (#188)
Browse files Browse the repository at this point in the history
This PR adds a new API that will allow decoupling injection targets from graphs.
  • Loading branch information
guyca authored Oct 31, 2024
1 parent 2ed1851 commit 4ef5c2a
Show file tree
Hide file tree
Showing 26 changed files with 237 additions and 48 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
sidebar_position: 6
title: "Service locator"
tags: [Service Locator]
---

## Obtaining dependencies imperatively
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
sidebar_position: 2
sidebar_position: 3
title: 'MediatorObservable'
tags: [MediatorObservable, Reactivity]
---
Expand Down
2 changes: 1 addition & 1 deletion packages/documentation/docs/reference/observable.mdx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
sidebar_position: 1
sidebar_position: 2
title: 'Observable'
tags: [Observable, Reactivity]
---
Expand Down
38 changes: 38 additions & 0 deletions packages/documentation/docs/reference/obsidian.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
sidebar_position: 1
title: 'Obsidian'
tags: [Service Locator]
---

`Obsidian` **exposes a set of functions that allow you to interact with the Obsidian framework imperatively.**

* [Reference](#reference)
* [obtain(keyOrGraph, props?)](#obtainkeyorgraph-props)
* [registerGraph(key, graphGenerator)](#registergraphkey-graphgenerator)
* [inject(target, keyOrGraph)](#injecttarget-keyorgraph)
___

## Reference
### obtain(keyOrGraph, props?): ServiceLocator
The `obtain` function is used to obtain a graph instance to be used as a [service locator](https://en.wikipedia.org/wiki/Service_locator_pattern).

#### Arguments
* `keyOrGraph?` - The class reference of the graph or its corresponding key if it was registered with a key.
* `props?` - An object containing props to be passed to the graph's constructor if this is the first time the graph is being instantiated.

#### Returns
* `ServiceLocator` - A service locator instance that can be used to resolve dependencies from the graph.

### registerGraph(key, graphGenerator)
The `registerGraph` function is used to register a graph generator function with a key. This allows the graph to be instantiated using the key instead of the class reference. This is useful when you want to decouple the graph's instantiation from its usage.

#### Arguments
* `key` - The key to register the graph with.
* `graphGenerator` - A function that returns the graph class reference. The generator function is called only when the graph is being instantiated. It's recommended to retrieve the class reference using inline require to delay side effects until the graph is actually used.

### inject(target, keyOrGraph)
The `inject` function is used to inject dependencies annotated with the `@LateInject` decorator into a class instance.

#### Arguments
* `target` - The class instance to inject the dependencies into.
* `keyOrGraph` - The class reference of the graph or its corresponding key if it was registered with a key.
2 changes: 1 addition & 1 deletion packages/documentation/docs/reference/useObserver.mdx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
sidebar_position: 3
sidebar_position: 4
title: 'useObserver'
tags: [useObserver, Reactivity]
---
Expand Down
2 changes: 1 addition & 1 deletion packages/documentation/docs/reference/useObservers.mdx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
sidebar_position: 4
sidebar_position: 5
title: 'useObservers'
tags: [useObservers, Reactivity]
---
Expand Down
4 changes: 3 additions & 1 deletion packages/react-obsidian/.eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
"obsidian"
],
"rules": {
"global-require": "off",
"no-console":"off",
"obsidian/unresolved-provider-dependencies": "error",
"obsidian/no-circular-dependencies": "warn",
Expand All @@ -37,7 +38,8 @@
{
"code": 115,
"comments": 200,
"ignoreRegExpLiterals": true
"ignoreRegExpLiterals": true,
"ignorePattern": "throw new Error\\(.*\\);|expect\\(.*\\)\\.toThrow\\(.*\\);"
}
],
"@stylistic/no-extra-semi": "error",
Expand Down
16 changes: 10 additions & 6 deletions packages/react-obsidian/src/Obsidian.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
import graphRegistry from './graph/registry/GraphRegistry';
import { ObjectGraph } from './graph/ObjectGraph';
import { GraphInternals, ServiceLocator } from './types';
import { GraphInternals, ServiceLocator, type Constructable } from './types';
import { GraphMiddleware } from './graph/registry/GraphMiddleware';
import lateInjector from './injectors/class/LateInjector';
import serviceLocatorFactory from './graph/ServiceLocatorFactory';

export default class Obsidian {
obtain<T extends ObjectGraph<P>, P>(
Graph: new(...args: P[]) => T,
registerGraph(key: string, generator: () => Constructable<ObjectGraph>) {
graphRegistry.registerGraphGenerator(key, generator);
}

obtain<T extends ObjectGraph<P>, P = unknown>(
keyOrGraph: string | (new(...args: P[]) => T),
props?: P,
): ServiceLocator<Omit<T, GraphInternals>> {
return serviceLocatorFactory.fromGraph(Graph, props);
return serviceLocatorFactory.fromGraph(keyOrGraph, props);
}

inject<T extends object>(target: T, graph?: ObjectGraph) {
return lateInjector.inject(target, graph);
inject<T extends object>(target: T, keyOrGraph?: string | ObjectGraph) {
return lateInjector.inject(target, keyOrGraph);
}

addGraphMiddleware(middleware: GraphMiddleware) {
Expand Down
4 changes: 2 additions & 2 deletions packages/react-obsidian/src/decorators/inject/Injectable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@ import { Graph } from '../../graph/Graph';
import graphRegistry from '../../graph/registry/GraphRegistry';
import ClassInjector from '../../injectors/class/ClassInjector';

export function Injectable(Graph: Constructable<Graph>): any {
return new ClassInjector(graphRegistry).inject(Graph);
export function Injectable(keyOrGraph: string | Constructable<Graph>): any {
return new ClassInjector(graphRegistry).inject(keyOrGraph);
}
4 changes: 2 additions & 2 deletions packages/react-obsidian/src/graph/ServiceLocatorFactory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { Constructable, ServiceLocator as ServiceLocatorType } from '../types';
import graphRegistry from './registry/GraphRegistry';

export default class ServiceLocatorFactory {
static fromGraph<T extends ObjectGraph<P>, P = any>(Graph: Constructable<T>, props?: P) {
const resolved = graphRegistry.resolve(Graph, 'serviceLocator', props);
static fromGraph<T extends ObjectGraph<P>, P = any>(keyOrGraph: string | Constructable<T>, props?: P) {
const resolved = graphRegistry.resolve(keyOrGraph, 'serviceLocator', props);
const wrapped = new Proxy(resolved, {
get(_target: any, property: string, receiver: any) {
return () => resolved.retrieve(property, receiver);
Expand Down
31 changes: 31 additions & 0 deletions packages/react-obsidian/src/graph/registry/GraphRegistry.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { mock } from 'jest-mock-extended';
import SingletonGraph from '../../../test/fixtures/SingletonGraph';
import MainGraph from '../../../test/fixtures/MainGraph';
import { GraphRegistry } from './GraphRegistry';
Expand Down Expand Up @@ -59,4 +60,34 @@ describe('GraphRegistry', () => {

expect(uut.resolve(ScopedLifecycleBoundGraph, 'lifecycleOwner', undefined, 'token')).not.toBe(graph);
});

it('resolves graph by key', () => {
uut.registerGraphGenerator('main', () => MainGraph);
expect(uut.resolve('main')).toBeInstanceOf(MainGraph);
});

it('throws an error when resolving a graph by key that is not registered', () => {
expect(() => uut.resolve('main')).toThrow('Attempted to resolve a graph by key "main" that is not registered. Did you forget to call Obsidian.registerGraph?');
});

it('clears graph generators', () => {
uut.registerGraphGenerator('main', () => MainGraph);
uut.clearAll();
expect(() => uut.resolve('main')).toThrow();
});

it('clears graph generator for a specific graph', () => {
uut.registerGraphGenerator('main', () => MainGraph);
const graph = uut.resolve('main');

uut.clear(graph);
expect(() => uut.resolve('main')).toThrow();
});

it('throws when registering a graph generator with the same key', () => {
uut.registerGraphGenerator('main', () => mock());
expect(
() => uut.registerGraphGenerator('main', () => mock()),
).toThrow('Attempted to register a graph generator for key "main" that is already registered.');
});
});
42 changes: 39 additions & 3 deletions packages/react-obsidian/src/graph/registry/GraphRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Middleware } from './Middleware';
import GraphMiddlewareChain from './GraphMiddlewareChain';
import { ObtainLifecycleBoundGraphException } from './ObtainLifecycleBoundGraphException';
import { getGlobal } from '../../utils/getGlobal';
import { isString } from '../../utils/isString';

export class GraphRegistry {
private readonly constructorToInstance = new Map<Constructable<Graph>, Set<Graph>>();
Expand All @@ -13,12 +14,21 @@ export class GraphRegistry {
private readonly nameToInstance = new Map<string, Graph>();
private readonly graphToSubgraphs = new Map<Constructable<Graph>, Set<Constructable<Graph>>>();
private readonly graphMiddlewares = new GraphMiddlewareChain();
private readonly keyToGenerator = new Map<string,() => Constructable<Graph>>();
private readonly keyToGraph = new Map<string, Constructable<Graph>>();

register(constructor: Constructable<Graph>, subgraphs: Constructable<Graph>[] = []) {
this.graphToSubgraphs.set(constructor, new Set(subgraphs));
}

ensureRegistered(graph: Graph) {
registerGraphGenerator(key: string, generator: () => Constructable<Graph>) {
if (this.keyToGenerator.has(key)) throw new Error(`Attempted to register a graph generator for key "${key}" that is already registered.`);
this.keyToGenerator.set(key, generator);
}

ensureRegistered(keyOrGraph: string | Graph) {
if (isString(keyOrGraph)) return;
const graph = keyOrGraph;
if (this.instanceToConstructor.get(graph)) return;
this.set(graph.constructor as any, graph);
}
Expand All @@ -34,12 +44,15 @@ export class GraphRegistry {
}

resolve<T extends Graph>(
Graph: Constructable<T>,
keyOrGraph: String | Constructable<T>,
source: 'lifecycleOwner' | 'classInjection' | 'serviceLocator' = 'lifecycleOwner',
props: any = undefined,
injectionToken?: string,
): T {
if ((this.isSingleton(Graph) || this.isBoundToReactLifecycle(Graph)) && this.has(Graph, injectionToken)) {
const Graph = isString(keyOrGraph) ?
this.getGraphConstructorByKey<T>(keyOrGraph) :
keyOrGraph as Constructable<T>;
if (( this.isSingleton(Graph) || this.isBoundToReactLifecycle(Graph)) && this.has(Graph, injectionToken)) {
return this.isComponentScopedLifecycleBound(Graph) ?
this.getByInjectionToken(Graph, injectionToken) :
this.getFirst(Graph);
Expand All @@ -52,6 +65,15 @@ export class GraphRegistry {
return graph as T;
}

private getGraphConstructorByKey<T extends Graph>(key: string): Constructable<T> {
if (this.keyToGraph.has(key)) return this.keyToGraph.get(key) as Constructable<T>;
const generator = this.keyToGenerator.get(key);
if (!generator) throw new Error(`Attempted to resolve a graph by key "${key}" that is not registered. Did you forget to call Obsidian.registerGraph?`);
const constructor = generator();
this.keyToGraph.set(key, constructor);
return constructor as Constructable<T>;
}

private has(Graph: Constructable<Graph>, injectionToken?: string): boolean {
const instances = this.constructorToInstance.get(Graph);
if (!instances) return false;
Expand Down Expand Up @@ -133,6 +155,18 @@ export class GraphRegistry {
this.injectionTokenToInstance.delete(token);
this.instanceToInjectionToken.delete(graph);
}

this.clearGraphsRegisteredByKey(Graph);
}

private clearGraphsRegisteredByKey(Graph: Constructable<Graph>) {
[...this.keyToGraph.keys()]
.map((key) => [key, this.keyToGraph.get(key)!] as [string, Constructable<Graph>])
.filter(([_, $Graph]) => $Graph === Graph)
.forEach(([key, _]) => {
this.keyToGraph.delete(key);
this.keyToGenerator.delete(key);
});
}

addGraphMiddleware(middleware: Middleware<Graph>) {
Expand All @@ -149,6 +183,8 @@ export class GraphRegistry {
this.nameToInstance.clear();
this.injectionTokenToInstance.clear();
this.instanceToInjectionToken.clear();
this.keyToGenerator.clear();
this.keyToGraph.clear();
}
}

Expand Down
8 changes: 4 additions & 4 deletions packages/react-obsidian/src/injectors/class/ClassInjector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,22 @@ export default class ClassInjector {
private injectionMetadata: InjectionMetadata = new InjectionMetadata(),
) {}

inject(Graph: Constructable<Graph>) {
inject(keyOrGraph: string | Constructable<Graph>) {
return (Target: Constructable<any>) => {
return new Proxy(Target, this.createProxyHandler(Graph, this.graphRegistry, this.injectionMetadata));
return new Proxy(Target, this.createProxyHandler(keyOrGraph, this.graphRegistry, this.injectionMetadata));
};
}

private createProxyHandler(
Graph: Constructable<Graph>,
keyOrGraph: string | Constructable<Graph>,
graphRegistry: GraphRegistry,
injectionMetadata: InjectionMetadata,
): ProxyHandler<any> {
return new class Handler implements ProxyHandler<any> {
construct(target: any, args: any[], newTarget: Function): any {
const isReactClassComponent = target.prototype?.isReactComponent;
const source = isReactClassComponent ? 'lifecycleOwner' : 'classInjection';
const graph = graphRegistry.resolve(Graph, source, args.length > 0 ? args[0] : undefined);
const graph = graphRegistry.resolve(keyOrGraph, source, args.length > 0 ? args[0] : undefined);
if (isReactClassComponent) {
referenceCounter.retain(graph);
}
Expand Down
13 changes: 10 additions & 3 deletions packages/react-obsidian/src/injectors/class/LateInjector.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,27 @@
import { isString } from 'lodash';
import { ObjectGraph } from '../../graph/ObjectGraph';
import graphRegistry from '../../graph/registry/GraphRegistry';
import InjectionMetadata from './InjectionMetadata';

export const GRAPH_INSTANCE_NAME_KEY = 'GRAPH_INSTANCE_NAME';

class LateInjector<T extends object> {
inject(target: T, sourceGraph?: ObjectGraph): T {
if (sourceGraph) graphRegistry.ensureRegistered(sourceGraph);
inject(target: T, keyOrGraph?: string | ObjectGraph): T {
if (keyOrGraph) graphRegistry.ensureRegistered(keyOrGraph);
const injectionMetadata = new InjectionMetadata();
const graph = sourceGraph ?? this.getGraphInstance(target);
const graph = this.getGraph(target, keyOrGraph);
injectionMetadata.getLatePropertiesToInject(target.constructor).forEach((key) => {
Reflect.set(target, key, graph.retrieve(key));
});
return target;
}

private getGraph(target: T, keyOrGraph?: string | ObjectGraph) {
if (keyOrGraph instanceof ObjectGraph) return keyOrGraph;
if (isString(keyOrGraph)) return graphRegistry.resolve(keyOrGraph, 'classInjection');
return this.getGraphInstance(target);
}

private getGraphInstance(target: T) {
const graphInstanceName = Reflect.getMetadata(GRAPH_INSTANCE_NAME_KEY, target.constructor);
return graphRegistry.getGraphInstance(graphInstanceName);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,24 +11,24 @@ import { useInjectionToken } from './useInjectionToken';
export default class ComponentInjector {
inject<P>(
Target: React.FunctionComponent<P>,
Graph: Constructable<ObjectGraph>,
keyOrGraph: string | Constructable<ObjectGraph>,
): React.FunctionComponent<Partial<P>> {
const Wrapped = this.wrapComponent(Target, Graph);
const Wrapped = this.wrapComponent(Target, keyOrGraph);
hoistNonReactStatics(Wrapped, Target);
return Wrapped;
}

private wrapComponent<P>(
InjectionCandidate: React.FunctionComponent<P>,
Graph: Constructable<ObjectGraph>,
keyOrGraph: string | Constructable<ObjectGraph>,
): React.FunctionComponent<Partial<P>> {
const isMemoized = isMemoizedComponent(InjectionCandidate);
const Target = isMemoized ? InjectionCandidate.type : InjectionCandidate;
const compare = isMemoized ? InjectionCandidate.compare : undefined;

return genericMemo((passedProps: P) => {
const injectionToken = useInjectionToken(Graph);
const graph = useGraph<P>(Graph, Target, passedProps, injectionToken);
const injectionToken = useInjectionToken(keyOrGraph);
const graph = useGraph<P>(keyOrGraph, Target, passedProps, injectionToken);
const proxiedProps = new PropsInjector(graph).inject(passedProps);

return (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { render } from '@testing-library/react';
import React from 'react';
import type { Constructable, ObjectGraph } from 'src';
import { Obsidian, type Constructable, type ObjectGraph } from '../..';
import MainGraph, { Dependencies } from '../../../test/fixtures/MainGraph';
import { injectComponent } from './InjectComponent';

Expand Down Expand Up @@ -35,7 +35,6 @@ describe('injectComponent', () => {
expect(container.textContent).toBe('error: own prop not provided - Fear kills progress');
});

// it throws an error if the Graph is undefined
it('Throws an error if the Graph is undefined', () => {
const Graph = undefined as unknown as Constructable<ObjectGraph>;
expect(() => injectComponent(component, Graph)).toThrowError(
Expand All @@ -45,4 +44,11 @@ describe('injectComponent', () => {
+ ` Check the implementation of component.`,
);
});

it('Injects component by registered graph key', () => {
Obsidian.registerGraph('MainGraph', () => MainGraph);
const InjectedComponent = injectComponent(component, 'MainGraph');
const { container } = render(<InjectedComponent />);
expect(container.textContent).toBe('error: own prop not provided - Fear kills progress');
});
});
Loading

0 comments on commit 4ef5c2a

Please sign in to comment.