Skip to content

Commit

Permalink
Custom lifecycle-bound scopes (#200)
Browse files Browse the repository at this point in the history
This PR introduces custom-scoped lifecycle bound graphs. Custom-scoped graphs are a special case of feature-scoped graphs. They differences between the two are:

* By default, subgraphs are instantiated lazily. Custom-scoped subgraphs are instantiated immediately when a parent graph with the same scope is instantiated.
* When instantiated, custom-scoped subgraphs receive the props of the custom-scoped graph that triggered their instantiation.
* Custom-scoped subgraphs can only be instantiated from a lifecycle bound graph with the same scope.
  • Loading branch information
guyca authored Dec 28, 2024
1 parent 7cd6950 commit f93dfff
Show file tree
Hide file tree
Showing 5 changed files with 212 additions and 2 deletions.
7 changes: 6 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,10 @@
],
"eslint.ignoreUntitled": true,
"eslint.format.enable": true,
"eslint.workingDirectories": [ {"pattern": "packages/*"} ]
"eslint.workingDirectories": [
{
"pattern": "packages/*"
}
],
"typescript.preferences.importModuleSpecifier": "relative"
}
29 changes: 29 additions & 0 deletions packages/documentation/docs/documentation/usage/Graphs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,35 @@ class FormGraph extends ObjectGraph {
Lifecycle-bound graphs are feature-scoped by default.
:::

3. **Custom scope**: A custom scope is a special case of a feature scope. When multiple `@LifecycleBound` graphs share the same custom scope, they are considered to be part of the same UI scope. When a custom scoped graph is requested, Obsidian will create all the subgraphs in the same UI scope and destroy them when the last component or hook that requested them is unmounted.

```ts title="A custom-scoped lifecycle-bound graph"
import {LifecycleBound, Graph, ObjectGraph, Provides} from 'react-obsidian';

@LifecycleBound({scope: 'AppScope'}) @Graph({subgraphs: [ScreenGraph]})
class HomeScreenGraph extends ObjectGraph {
constructor(private props: HomeScreenProps & BaseProps) {
super(props);
}
}
```

```ts title="A custom-scoped lifecycle-bound subgraph"
@LifecycleBound({scope: 'AppScope'}) @Graph()
class ScreenGraph extends ObjectGraph {
constructor(private props: BaseProps) {
super(props);
}
}
```

:::info
The differences between a feature-scoped graph and a custom-scoped graph:
1. By default, subgraphs are instantiated lazily. Custom-scoped subgraphs are instantiated immediately when a parent graph with the same scope is instantiated.
2. When instantiated, custom-scoped subgraphs receive the props of the custom-scoped graph that triggered their instantiation.
3. Custom-scoped subgraphs can only be instantiated from a lifecycle bound graph with the same scope.
:::

#### Passing props to a lifecycle-bound graph
When a graph is created, it receives the props of the component or hook that requested it. This means that the graph can use the props to construct the dependencies it provides. The `@LifecycleBound` in the example below graph provides a `userService` which requires a `userId`. The `userId` is obtained from props.

Expand Down
2 changes: 1 addition & 1 deletion packages/react-obsidian/src/decorators/LifecycleBound.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
type Options = {
scope?: 'component' | 'feature';
scope?: 'component' | 'feature' | (string & {});
};

export function LifecycleBound(options?: Options) {
Expand Down
75 changes: 75 additions & 0 deletions packages/react-obsidian/src/graph/registry/GraphRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import GraphMiddlewareChain from './GraphMiddlewareChain';
import { ObtainLifecycleBoundGraphException } from './ObtainLifecycleBoundGraphException';
import { getGlobal } from '../../utils/getGlobal';
import { isString } from '../../utils/isString';
import referenceCounter from '../../ReferenceCounter';

export class GraphRegistry {
private readonly constructorToInstance = new Map<Constructable<Graph>, Set<Graph>>();
Expand All @@ -16,6 +17,7 @@ export class GraphRegistry {
private readonly graphMiddlewares = new GraphMiddlewareChain();
private readonly keyToGenerator = new Map<string,() => Constructable<Graph>>();
private readonly keyToGraph = new Map<string, Constructable<Graph>>();
private readonly onClearListeners = new Map<Graph, Set<() => void>>();

register(constructor: Constructable<Graph>, subgraphs: Constructable<Graph>[] = []) {
this.graphToSubgraphs.set(constructor, new Set(subgraphs));
Expand All @@ -33,6 +35,10 @@ export class GraphRegistry {
this.set(graph.constructor as any, graph);
}

public isInstantiated(G: Constructable<Graph>): boolean {
return (this.constructorToInstance.get(G)?.size ?? 0) > 0;
}

getSubgraphs(graph: Graph): Graph[] {
const Graph = this.instanceToConstructor.get(graph)!;
const subgraphs = this.graphToSubgraphs.get(Graph) ?? new Set();
Expand Down Expand Up @@ -62,9 +68,58 @@ export class GraphRegistry {
}
const graph = this.graphMiddlewares.resolve(Graph, props);
this.set(Graph, graph, injectionToken);
this.instantiateCustomScopedSubgraphs(graph, props);
return graph as T;
}

private instantiateCustomScopedSubgraphs(graph: Graph, props: any) {
this.assertInstantiatingCustomScopedSubgraphFromSameScope(graph);
if (!this.isCustomScopedLifecycleBound(this.instanceToConstructor.get(graph)!)) return;
const customScope = Reflect.getMetadata('lifecycleScope', this.instanceToConstructor.get(graph)!);
const subgraphs = this.getSubgraphsConstructors(graph);
const sameScopeSubgraphs = subgraphs.filter(
subgraph => Reflect.getMetadata('lifecycleScope', subgraph) === customScope,
);
const instantiatedSubgraphs = sameScopeSubgraphs.map(
subgraph => {
return this.resolve(subgraph, 'lifecycleOwner', props);
},
);
instantiatedSubgraphs.forEach((subgraph) => referenceCounter.retain(subgraph));
this.registerOnClearListener(graph, () => {
instantiatedSubgraphs.forEach((subgraph) => referenceCounter.release(subgraph, () => this.clear(subgraph)));
});
}

private assertInstantiatingCustomScopedSubgraphFromSameScope(graph: Graph) {
const graphScope = Reflect.getMetadata('lifecycleScope', this.instanceToConstructor.get(graph)!);
const subgraphs = this.getSubgraphsConstructors(graph);
subgraphs.forEach(subgraph => {
const subgraphScope = Reflect.getMetadata('lifecycleScope', subgraph);
if (
!this.isInstantiated(subgraph) &&
this.isCustomScopedLifecycleBound(subgraph) &&
graphScope !== subgraphScope
) {
throw new Error(`Cannot instantiate the scoped graph '${subgraph.name}' as a subgraph of '${graph.constructor.name}' because the scopes do not match. ${graphScope} !== ${subgraphScope}`);
}
});
}

private getSubgraphsConstructors(graph: Graph | Constructable<Graph>): Constructable<Graph>[] {
const Graph = typeof graph === 'function' ? graph : this.instanceToConstructor.get(graph)!;
const directSubgraphs = Array.from(this.graphToSubgraphs.get(Graph) ?? new Set<Constructable<Graph>>());
if (directSubgraphs.length === 0) return [];
return [
...directSubgraphs,
...new Set(
directSubgraphs
.map(subgraph => this.getSubgraphsConstructors(subgraph))
.flat(),
),
];
}

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);
Expand Down Expand Up @@ -123,6 +178,11 @@ export class GraphRegistry {
return Reflect.getMetadata('lifecycleScope', Graph) === 'component';
}

private isCustomScopedLifecycleBound(Graph: Constructable<Graph>): boolean {
const scope = Reflect.getMetadata('lifecycleScope', Graph);
return typeof scope === 'string' && scope !== 'component' && scope !== 'feature';
}

clearGraphAfterItWasMockedInTests(graphName: string) {
const graphNames = this.nameToInstance.keys();
for (const name of graphNames) {
Expand All @@ -140,6 +200,7 @@ export class GraphRegistry {
this.injectionTokenToInstance.delete(token);
this.instanceToInjectionToken.delete(graph);
}
this.invokeOnClearListeners(graph);
}
}
}
Expand All @@ -157,6 +218,20 @@ export class GraphRegistry {
}

this.clearGraphsRegisteredByKey(Graph);
this.invokeOnClearListeners(graph);
}

private registerOnClearListener(graph: Graph, callback: () => void) {
const listeners = this.onClearListeners.get(graph) ?? new Set();
listeners.add(callback);
this.onClearListeners.set(graph, listeners);
}

private invokeOnClearListeners(graph: Graph) {
const listeners = this.onClearListeners.get(graph);
if (!listeners) return;
listeners.forEach((listener) => listener());
this.onClearListeners.delete(graph);
}

private clearGraphsRegisteredByKey(Graph: Constructable<Graph>) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import React from 'react';
import { render } from '@testing-library/react';
import {
Graph,
injectComponent,
LifecycleBound,
ObjectGraph,
Singleton,
} from '../../src';
import graphRegistry from '../../src/graph/registry/GraphRegistry';

describe('custom scoped lifecycle-bound graphs', () => {
it('instantiates custom scoped graphs eagerly', () => {
render(<ComponentTheDoesNotInvokeProviders idx={1} />);
expect(graphRegistry.isInstantiated(CustomScopeGraph)).toBe(true);
});

it('instantiates the custom scoped graphs once', () => {
render(<ComponentTheDoesNotInvokeProviders idx={1} />);
render(<ComponentTheDoesNotInvokeProviders idx={2} />);
expect(CustomScopeGraph.idx).toBe(1);
});

it('clears the custom scoped subgraph when the main graph is cleared', async () => {
const {unmount} = render(<ComponentTheDoesNotInvokeProviders idx={1} />);
unmount();
expect(graphRegistry.isInstantiated(CustomScopeGraph)).toBe(false);
});

it('clears the custom scoped subgraph only when no other graphs are using it', async () => {
const result1 = render(<ComponentTheDoesNotInvokeProviders idx={1} />);
const result2 = render(<ComponentTheDoesNotInvokeProviders2 />);

result1.unmount();
expect(graphRegistry.isInstantiated(CustomScopeGraph)).toBe(true);
result2.unmount();
expect(graphRegistry.isInstantiated(CustomScopeGraph)).toBe(false);
});

it('throws when trying to use a scoped subgraph from an unscoped graph', async () => {
expect(() => {
render(<ComponentThatWronglyReliesOnCustomScopedGraph />);
}).toThrow(/Cannot instantiate the scoped graph 'CustomScopeGraph' as a subgraph of 'UnscopedGraph' because the scopes do not match. undefined !== customScope/);
});

it('eagerly instantiates nested scoped graphs', () => {
render(<ComponentThatReliesOnNestedCustomScopedGraph />);
expect(graphRegistry.isInstantiated(CustomScopeGraph)).toBe(true);
});
});

@LifecycleBound({scope: 'customScope'}) @Graph()
class CustomScopeGraph extends ObjectGraph {
public static idx: number;

constructor(props: Own) {
super();
CustomScopeGraph.idx = props.idx;
}
}

@LifecycleBound({scope: 'customScope'}) @Graph({subgraphs: [CustomScopeGraph]})
class ComponentGraph extends ObjectGraph {
}

@LifecycleBound({scope: 'customScope'}) @Graph({subgraphs: [CustomScopeGraph]})
class ComponentGraph2 extends ObjectGraph {
}

type Own = {idx: number};
const ComponentTheDoesNotInvokeProviders = injectComponent<Own>(
({idx}: Own) => <>Hello {idx}</>,
ComponentGraph,
);

const ComponentTheDoesNotInvokeProviders2 = injectComponent(
() => <>Hello</>,
ComponentGraph2,
);

@Graph({subgraphs: [CustomScopeGraph]})
class UnscopedGraph extends ObjectGraph {
}

const ComponentThatWronglyReliesOnCustomScopedGraph = injectComponent(
() => <>This should error</>,
UnscopedGraph,
);

@Singleton() @Graph({subgraphs: [CustomScopeGraph]})
class SingletonGraphWithCustomScopeSubgraph extends ObjectGraph {
}

@LifecycleBound({scope: 'customScope'}) @Graph({subgraphs: [SingletonGraphWithCustomScopeSubgraph]})
class CustomScopedGraphWithNestedCustomScopeSubgraph extends ObjectGraph {
}

const ComponentThatReliesOnNestedCustomScopedGraph = injectComponent(
() => <>Hello</>,
CustomScopedGraphWithNestedCustomScopeSubgraph,
);

0 comments on commit f93dfff

Please sign in to comment.