Skip to content

Commit 8c720f7

Browse files
guycaCopilot
andauthored
feat: support React 19 Activity API (#289)
Adds `inactiveBehavior` option to `@lifecycleBound` to control what happens to a graph when its host component is hidden inside a React 19 `<Activity>` tree. Also fixes `useObservable` hooks so they re-apply when the component is unpaused. - New `inactiveBehavior: 'unmount' | 'retain'` option on `@lifecycleBound` (default: `'retain'`) - When `retain`, a sentinel DOM node detects whether the cleanup effect fired due to an Activity pause vs a real unmount — skipping graph teardown in the former case - When `unmount`, the graph is cleared on every unmount as before - `useObservable` hooks are now re-applied when a component resumes from an Activity pause (previously observers were not re-registered after unpause) - Bump `react`/`react-dom` devDependencies to 19.2.4 (Activity API; consumer peerDep range `>=16.8.0` unchanged) - Document the new option in Graphs.mdx --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: guyca <12352397+guyca@users.noreply.github.com>
1 parent 9ec2295 commit 8c720f7

26 files changed

Lines changed: 643 additions & 221 deletions

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
## Target platforms
2+
This library targets both web bundlers (webpack/vite) and React Native (Metro/Hermes).

packages/documentation/docs/documentation/usage/Graphs.mdx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
sidebar_position: 1
3-
tags: [Graph, Lifecycle-bound]
3+
tags: [Graph, Lifecycle-bound, Inactive-behavior]
44
---
55

66
import Tabs from '@theme/Tabs';
@@ -175,6 +175,28 @@ class HomeGraph extends ObjectGraph<HomeScreenProps> {
175175
#### The lifecycle of a lifecycle-bound graph
176176
Lifecycle-bound graphs are created when they are requested and are destroyed when the last component or hook that requested them is unmounted. This means that the dependencies provided by a lifecycle-bound graph are shared between components and hooks within the same UI scope and are destroyed when the UI scope is destroyed.
177177

178+
#### Inactive behavior
179+
By default, lifecycle-bound graphs are retained during transient `useEffect` cleanups — such as React Native activity pauses or React StrictMode remounts — and only cleared when the component is actually removed from the tree. This prevents the graph from being destroyed and recreated unnecessarily.
180+
181+
Setting `inactiveBehavior` to `'unmount'` reverts to eager cleanup, where the graph is cleared on every `useEffect` cleanup regardless of whether the component has truly unmounted.
182+
183+
```ts title="A lifecycle-bound graph with eager cleanup"
184+
import {lifecycleBound, graph, ObjectGraph, provides} from 'react-obsidian';
185+
186+
@lifecycleBound({ inactiveBehavior: 'unmount' }) @graph()
187+
class AuthGraph extends ObjectGraph {
188+
@provides()
189+
userService(): UserService {
190+
return new UserService();
191+
}
192+
}
193+
```
194+
195+
| Value | Behavior |
196+
|-------|----------|
197+
| `'retain'` (default) | Graph retained during transient cleanups; cleared only on real unmount |
198+
| `'unmount'` | Graph cleared on every `useEffect` cleanup |
199+
178200
## Graph composition
179201
Graph composition is a powerful feature that allows you to create complex dependency graphs by combining smaller graphs. Composing graphs is useful when you want to reuse a graph in multiple places. For example, you might have a singleton graph that provides application-level dependencies. You might also have a lifecycle-bound graph that provides dependencies for a specific UI flow. You can compose these graphs together so that the lifecycle-bound graph can also inject the dependencies provided by the singleton graph.
180202

packages/eslint-plugin-obsidian/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "eslint-plugin-obsidian",
33
"description": "ESLint rules for Obsidian",
44
"main": "dist/index.js",
5-
"version": "2.30.0",
5+
"version": "2.31.0-alpha.8",
66
"scripts": {
77
"build": "npx tsc --project tsconfig.prod.json",
88
"test": "npx jest --colors",
@@ -25,7 +25,7 @@
2525
"dependencies": {
2626
"lodash": "^4.17.21",
2727
"ts-morph": "^25.0.1",
28-
"ts-morph-extensions": "^2.30.0"
28+
"ts-morph-extensions": "^2.31.0-alpha.8"
2929
},
3030
"devDependencies": {
3131
"@babel/core": "7.26.10",
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
11
import 'jest-extended';
2+
import '@testing-library/jest-dom';
3+
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
require('setimmediate');
22
require('./clearGraphs');
3+
require('@testing-library/jest-dom');
34

packages/react-obsidian/package.json

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "react-obsidian",
3-
"version": "2.30.0",
3+
"version": "2.31.0-alpha.8",
44
"description": "Dependency injection framework for React and React Native applications",
55
"scripts": {
66
"prepack": "yarn lint && tsc --project tsconfig.prod.json",
@@ -39,13 +39,15 @@
3939
"@babel/preset-typescript": "7.26.0",
4040
"@babel/types": "7.24.5",
4141
"@stylistic/eslint-plugin": "^1.7.0",
42-
"@testing-library/react": "14.x.x",
42+
"@testing-library/dom": "^10.0.0",
43+
"@testing-library/jest-dom": "^6.9.1",
44+
"@testing-library/react": "16.x.x",
4345
"@types/hoist-non-react-statics": "^3.3.1",
4446
"@types/jest": "^30.0.0",
4547
"@types/jest-when": "^3.5.5",
4648
"@types/lodash": "^4.14.176",
47-
"@types/react": "18.3.x",
48-
"@types/react-dom": "18.3.x",
49+
"@types/react": "^19.0.0",
50+
"@types/react-dom": "^19.0.0",
4951
"@typescript-eslint/eslint-plugin": "^7.18.0",
5052
"@typescript-eslint/parser": "^7.18.0",
5153
"babel-plugin-parameter-decorator": "1.x.x",
@@ -65,8 +67,8 @@
6567
"jest-mock-extended": "^3.0.7",
6668
"jest-when": "^3.7.0",
6769
"lodash": "^4.17.21",
68-
"react": "18.2.x",
69-
"react-dom": "18.2.x",
70+
"react": "19.2.4",
71+
"react-dom": "19.2.4",
7072
"setimmediate": "^1.0.5",
7173
"typescript": "^5.7.3"
7274
},

packages/react-obsidian/src/decorators/LifecycleBound.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ import {Reflect} from '../utils/reflect';
22

33
type Options = {
44
scope?: 'component' | 'feature' | (string & {});
5+
inactiveBehavior?: 'unmount' | 'retain';
56
};
67

78
export function lifecycleBound(options?: Options) {
89
return (constructor: any) => {
910
Reflect.defineMetadata('isLifecycleBound', true, constructor);
1011
Reflect.defineMetadata('lifecycleScope', options?.scope ?? 'feature', constructor);
12+
Reflect.defineMetadata('inactiveBehavior', options?.inactiveBehavior ?? 'retain', constructor);
1113
return constructor;
1214
};
1315
}

packages/react-obsidian/src/graph/ObjectGraph.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ import { getConstructorOrParentConstructor } from '../utils/object';
1010
export abstract class ObjectGraph<T = unknown> implements Graph {
1111
private propertyRetriever = new PropertyRetriever(this);
1212

13+
get inactiveBehavior(): 'unmount' | 'retain' {
14+
return Reflect.getMetadata('inactiveBehavior', this.constructor) ?? 'unmount';
15+
}
16+
1317
get name(): string {
1418
const target = getConstructorOrParentConstructor(this.constructor, ObjectGraph.name);
1519
if (Reflect.hasMetadata('memoizedName', target)) {

packages/react-obsidian/src/injectors/components/ComponentInjector.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import React, { PropsWithChildren } from 'react';
1+
import React, { PropsWithChildren, useRef } from 'react';
22
import hoistNonReactStatics from 'hoist-non-react-statics';
33
import { ObjectGraph } from '../../graph/ObjectGraph';
44
import PropsInjector from './PropsInjector';
55
import useGraph from './useGraph';
6+
import Sentinel from './Sentinel';
67
import { Constructable } from '../../types';
78
import { genericMemo, isMemoizedComponent } from '../../utils/React';
89
import { GraphContext } from './graphContext';
@@ -14,7 +15,7 @@ export default class ComponentInjector {
1415
keyOrGraph: string | Constructable<ObjectGraph>,
1516
): React.FunctionComponent<Partial<P>> {
1617
const Wrapped = this.wrapComponent(Target, keyOrGraph);
17-
hoistNonReactStatics(Wrapped, Target);
18+
hoistNonReactStatics(Wrapped as any, Target as any);
1819
return Wrapped;
1920
}
2021

@@ -28,12 +29,14 @@ export default class ComponentInjector {
2829

2930
return genericMemo((passedProps: P) => {
3031
const injectionToken = useInjectionToken(keyOrGraph);
31-
const graph = useGraph<P>(keyOrGraph, Target, passedProps, injectionToken);
32+
const containerRef = useRef(null);
33+
const graph = useGraph<P>(keyOrGraph, Target, passedProps, injectionToken, containerRef);
3234
const proxiedProps = new PropsInjector(graph).inject(passedProps);
3335

3436
return (
3537
<GraphContext.Provider value={{injectionToken}}>
36-
{Target(proxiedProps as unknown as PropsWithChildren<P>)}
38+
{graph.inactiveBehavior === 'retain' && <Sentinel ref={containerRef} />}
39+
{Target(proxiedProps as unknown as PropsWithChildren<P>) as React.ReactNode}
3740
</GraphContext.Provider>
3841
);
3942
}, compare);
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import React, { forwardRef } from 'react';
2+
3+
// eslint-disable-next-line @typescript-eslint/no-require-imports, global-require
4+
const { View } = require('react-native');
5+
6+
const style = { position: 'absolute' as const, width: 0, height: 0, overflow: 'hidden' as const };
7+
8+
const Sentinel = forwardRef<any>((_, ref) => (
9+
<View ref={ref} collapsable={false} style={style} />
10+
));
11+
12+
export default Sentinel;

0 commit comments

Comments
 (0)