diff --git a/.vscode/settings.json b/.vscode/settings.json index f83dd8a0..949f4e9c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -13,5 +13,10 @@ ], "eslint.ignoreUntitled": true, "eslint.format.enable": true, - "eslint.workingDirectories": [ {"pattern": "packages/*"} ] + "eslint.workingDirectories": [ + { + "pattern": "packages/*" + } + ], + "typescript.preferences.importModuleSpecifier": "relative" } \ No newline at end of file diff --git a/packages/documentation/docs/documentation/usage/Graphs.mdx b/packages/documentation/docs/documentation/usage/Graphs.mdx index e3a8753c..0e6dfce7 100644 --- a/packages/documentation/docs/documentation/usage/Graphs.mdx +++ b/packages/documentation/docs/documentation/usage/Graphs.mdx @@ -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. diff --git a/packages/react-obsidian/src/decorators/LifecycleBound.ts b/packages/react-obsidian/src/decorators/LifecycleBound.ts index 9a720e29..4d3f25c7 100644 --- a/packages/react-obsidian/src/decorators/LifecycleBound.ts +++ b/packages/react-obsidian/src/decorators/LifecycleBound.ts @@ -1,5 +1,5 @@ type Options = { - scope?: 'component' | 'feature'; + scope?: 'component' | 'feature' | (string & {}); }; export function LifecycleBound(options?: Options) { diff --git a/packages/react-obsidian/src/graph/registry/GraphRegistry.ts b/packages/react-obsidian/src/graph/registry/GraphRegistry.ts index 4bf2ea10..60a437e4 100644 --- a/packages/react-obsidian/src/graph/registry/GraphRegistry.ts +++ b/packages/react-obsidian/src/graph/registry/GraphRegistry.ts @@ -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, Set>(); @@ -16,6 +17,7 @@ export class GraphRegistry { private readonly graphMiddlewares = new GraphMiddlewareChain(); private readonly keyToGenerator = new Map Constructable>(); private readonly keyToGraph = new Map>(); + private readonly onClearListeners = new Map void>>(); register(constructor: Constructable, subgraphs: Constructable[] = []) { this.graphToSubgraphs.set(constructor, new Set(subgraphs)); @@ -33,6 +35,10 @@ export class GraphRegistry { this.set(graph.constructor as any, graph); } + public isInstantiated(G: Constructable): 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(); @@ -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): Constructable[] { + const Graph = typeof graph === 'function' ? graph : this.instanceToConstructor.get(graph)!; + const directSubgraphs = Array.from(this.graphToSubgraphs.get(Graph) ?? new Set>()); + if (directSubgraphs.length === 0) return []; + return [ + ...directSubgraphs, + ...new Set( + directSubgraphs + .map(subgraph => this.getSubgraphsConstructors(subgraph)) + .flat(), + ), + ]; + } + private getGraphConstructorByKey(key: string): Constructable { if (this.keyToGraph.has(key)) return this.keyToGraph.get(key) as Constructable; const generator = this.keyToGenerator.get(key); @@ -123,6 +178,11 @@ export class GraphRegistry { return Reflect.getMetadata('lifecycleScope', Graph) === 'component'; } + private isCustomScopedLifecycleBound(Graph: Constructable): 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) { @@ -140,6 +200,7 @@ export class GraphRegistry { this.injectionTokenToInstance.delete(token); this.instanceToInjectionToken.delete(graph); } + this.invokeOnClearListeners(graph); } } } @@ -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) { diff --git a/packages/react-obsidian/test/integration/customScopedLifecycleBoundGraphs.test.tsx b/packages/react-obsidian/test/integration/customScopedLifecycleBoundGraphs.test.tsx new file mode 100644 index 00000000..4cb05b05 --- /dev/null +++ b/packages/react-obsidian/test/integration/customScopedLifecycleBoundGraphs.test.tsx @@ -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(); + expect(graphRegistry.isInstantiated(CustomScopeGraph)).toBe(true); + }); + + it('instantiates the custom scoped graphs once', () => { + render(); + render(); + expect(CustomScopeGraph.idx).toBe(1); + }); + + it('clears the custom scoped subgraph when the main graph is cleared', async () => { + const {unmount} = render(); + 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(); + const result2 = render(); + + 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(); + }).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(); + 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( + ({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, +);