Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Custom lifecycle-bound scopes #200

Merged
merged 9 commits into from
Dec 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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,
);
Loading