Skip to content

Commit fb9fbaf

Browse files
committed
merge: adapter guide and runnable example
2 parents 4db97c6 + 0e50603 commit fb9fbaf

23 files changed

Lines changed: 1367 additions & 229 deletions

README.md

Lines changed: 151 additions & 151 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,56 @@
11
# Illuma React (Experimental)
22

3-
Experimental React adapter for [@illuma/core](https://github.com/git-illuma/core) dependency injection container.
4-
This package provides React bindings for Illuma DI system and a lightweight signals implementation for state management.
3+
Experimental React adapter for the [@illuma/core](https://github.com/git-illuma/core)
4+
dependency injection container.
5+
6+
The adapter's job is to make React's tree and the container's tree the same tree:
7+
a component subtree gets its own container, resolution walks upwards through
8+
React's context, and a container's lifetime is a mount. It also ships a one-hook
9+
bridge to [@illuma/signals](https://github.com/git-illuma/signals) so a service's
10+
state can drive a render.
511

612
## Features
7-
- **React Bindings** – Context-based dependency injection for React components
8-
- **Scope Support** – Create child containers for component sub-trees
9-
- **Signals** – Fine-grained reactivity system for state management
10-
- **React Hooks**`useDependency` for DI and `useSignal` for state
13+
14+
- **React bindings** – context-based dependency injection for React components
15+
- **Scopes** – child containers for a component subtree, disposed with it
16+
- **Lifecycle**`onMount` / `onUnmount` hooks for services that own a resource
17+
- **Signals bridge**`useSignal` subscribes a component to a signal
18+
- **Diagnostics** – opt-in reporting of providers nothing ever injected
19+
- **Testkit** – a container the test owns, with a ready-made wrapper
1120

1221
## Installation
1322

1423
```bash
15-
yarn add @illuma/react-experimental @illuma/core
24+
yarn add @illuma/react-experimental @illuma/core @illuma/signals
1625
```
1726

27+
All three peers are required. `@illuma/signals` is only used by the `/signals`
28+
entry point, but it is declared as a plain peer dependency, so a package manager
29+
will ask for it either way.
30+
1831
Requires `@illuma/core` 2.5.0 or newer: scopes are built on the container's
1932
`weakParentLink` option, so that a container React builds during a render it later
2033
throws away can be collected instead of being retained by its parent forever.
2134

2235
## Structure
2336

24-
- `@illuma/react-experimental` – Dependency injection bindings and React integration
25-
- `@illuma/react-experimental/signals` – Signals implementation for state management outside of React's render cycle
26-
- `@illuma/react-experimental/testkit` – Container-backed wrapper for testing components
37+
- `@illuma/react-experimental` – dependency injection bindings and React integration
38+
- `@illuma/react-experimental/signals` – re-exports `@illuma/signals` and adds the `useSignal` hook
39+
- `@illuma/react-experimental/testkit` – container-backed wrapper for testing components
40+
41+
## Example
42+
43+
[`example/`](./example) is a small application that puts every section of this
44+
document into one tree — a root container, a scope per screen, a service that
45+
owns a subscription, signals rendered through `useSignal`, a subtree that
46+
rebinds one token, and a test that swaps that token for a fake. It builds, runs
47+
and tests:
48+
49+
```bash
50+
bun run build # the package
51+
cd example && npx vite # http://localhost:5175
52+
cd example && npx vitest run # 6 tests
53+
```
2754

2855
## Setup
2956

@@ -43,6 +70,10 @@ export const App = () => (
4370
);
4471
```
4572

73+
Keep that array module-level. `providers` is read once, when the container is
74+
built; a fresh literal on every render would be silently ignored, and the
75+
adapter says so in development.
76+
4677
## Dependency Injection
4778

4879
A detailed guide on how **@illuma/core** DI system works can be found in the [Docs](https://github.com/git-illuma/core/blob/main/docs/GETTING_STARTED.md).
@@ -122,6 +153,10 @@ export const Dashboard = () => (
122153

123154
In this example, `FeatureSection` and its children will use `MockUserService`, while `Dashboard` and its children (`DashboardComponent`) will use the original `UserService`.
124155

156+
Overriding means shadowing in a *child* container. Listing two providers for one
157+
token in the same container is an error, not a last-one-wins — which is worth
158+
knowing when you lay out a provider array you also intend to reuse in tests.
159+
125160
### Resolution Modifiers
126161

127162
`useDependency` forwards the container's modifiers.
@@ -139,9 +174,13 @@ throws — a broken dependency is a bug, not an absent one.
139174

140175
Two rules matter, and both come from the container rather than from React.
141176

142-
**A constructor runs twice.** The container executes each factory once against proxy
143-
dependencies to measure the graph, then once for real. A constructor must therefore be
144-
pure: build fields, inject dependencies, and nothing else.
177+
**A constructor runs more than once.** The container executes each factory once
178+
against proxy dependencies to measure the graph, then once for real — and React
179+
is free to build a container it later discards, which buys another pair. Under
180+
`StrictMode` a provider's constructor is observed to run three times for the one
181+
instance that survives. The number is not a contract; the rule it forces is.
182+
A constructor may build fields and inject dependencies, and must cause nothing
183+
to happen.
145184

146185
**Resources belong to the mount, not to the constructor.** React may render a component,
147186
build its container, and then discard the whole attempt without ever committing it — and
@@ -174,84 +213,67 @@ export const ClockService = makeInjectable(_ClockService);
174213
`onMount` runs when the group commits, `onUnmount` when it goes away. Hooks fire only for
175214
nodes registered in that same container, so a nested group never re-mounts its ancestors'.
176215

177-
## Server Rendering
178-
179-
Effects never run on a server, so nothing there can own a container's lifetime. Build one
180-
per request and hand it to `IllumaRoot`, which then only publishes it — never bootstraps,
181-
rebuilds, or destroys it.
182-
183-
```tsx
184-
const container = new NodeContainer({ instant: false });
185-
container.provide(requestProviders);
186-
container.bootstrap();
187-
188-
try {
189-
return renderToString(
190-
<IllumaRoot container={container}>
191-
<App />
192-
</IllumaRoot>,
193-
);
194-
} finally {
195-
container.destroy();
196-
}
197-
```
216+
A service with hooks therefore needs two entries for one class. Provider arrays
217+
nest, so the pair can travel as a single exported constant:
198218

199-
## Testing
200-
201-
`createTestScope` builds a container the test owns, and returns a wrapper for
202-
`@testing-library/react`. Swapping one token restages the whole graph beneath it.
203-
204-
```tsx
205-
import { createTestScope } from '@illuma/react-experimental/testkit';
206-
207-
const scope = createTestScope({
208-
providers: [{ provide: ApiService, useClass: FakeApi }],
209-
});
210-
211-
render(<TodoList />, { wrapper: scope.wrapper });
212-
213-
expect(scope.container.get(ApiService)).toBeInstanceOf(FakeApi);
214-
scope.destroy();
219+
```ts
220+
export const clockProviders: Provider = [
221+
ClockService,
222+
{ provide: LIFECYCLE_NODE, alias: ClockService },
223+
];
215224
```
216225

217-
The container outlives the tree, so it can still be inspected after an unmount.
226+
## Signals
218227

219-
## Diagnostics
228+
The reactivity engine is not part of this package. It lives in
229+
[@illuma/signals](https://github.com/git-illuma/signals), knows nothing about
230+
React, and is documented there — `signal`, `computed`, `linkedSignal`,
231+
`resource`, `external`, `untracked` and their options.
220232

221-
Opt in during development to be told which providers nothing ever injected.
233+
What this package adds is the bridge, plus a re-export so you only need one
234+
import path in React code:
222235

223236
```ts
224-
import { enableReactDiagnostics } from '@illuma/react-experimental';
225-
226-
if (import.meta.env.DEV) enableReactDiagnostics();
237+
// both work; the second saves you a second dependency in the import list
238+
import { signal, computed } from '@illuma/signals';
239+
import { signal, computed } from '@illuma/react-experimental/signals';
227240
```
228241

229-
Output goes through `Illuma.setLogger`, sharing one control surface with the core's own
230-
diagnostics. It is a no-op in production builds.
231-
232-
## Signals
242+
### `useSignal`
233243

234-
This package includes a lightweight signals implementation to manage state outside of React's render cycle effectively.
244+
Subscribes a component to a signal and re-renders it when the value changes.
245+
Built on `useSyncExternalStore`, with the same read used as the server snapshot,
246+
so it is safe under `renderToString`.
235247

236-
### Creating Signals
248+
```tsx
249+
import { useSignal } from '@illuma/react-experimental/signals';
250+
import { useDependency } from '@illuma/react-experimental';
237251

238-
Signals can be created standalone:
252+
export const Counter = () => {
253+
const service = useDependency(CounterService);
254+
const count = useSignal(service.count);
255+
const double = useSignal(service.double);
239256

240-
```typescript
241-
import { computed, signal } from '@illuma/react-experimental/signals';
257+
return (
258+
<div>
259+
<div>{count} / {double}</div>
260+
<button onClick={() => service.increment()}>+1</button>
261+
</div>
262+
);
263+
};
264+
```
242265

243-
const count = signal(0);
244-
const double = computed(() => count() * 2);
266+
It takes a signal, not a value, and throws if handed anything else.
245267

246-
count.set(1);
247-
console.log(double()); // 2
248-
```
268+
### Where the state should live
249269

250-
Or as part of a service:
270+
Put the signals on the service and keep derivations there too. React then
271+
subscribes to a finished value instead of recomputing one on every render, and
272+
the same state is reachable from code that has no component around it.
251273

252-
```typescript
274+
```ts
253275
import { makeInjectable } from '@illuma/core';
254-
import { computed, signal } from '@illuma/react-experimental/signals';
276+
import { computed, signal } from '@illuma/signals';
255277

256278
class _CounterService {
257279
public readonly count = signal(0);
@@ -262,107 +284,85 @@ class _CounterService {
262284
}
263285
}
264286

287+
export type CounterService = _CounterService;
265288
export const CounterService = makeInjectable(_CounterService);
266-
export type CounterService = ReturnType<typeof CounterService>;
267289
```
268290

269-
Then inject and use in a component:
291+
Creating signals in a constructor or a field initializer is fine: they are
292+
values, not effects, and the copy built during the container's measuring pass is
293+
simply discarded.
270294

271-
```tsx
272-
import { useDependency } from '@illuma/react-experimental';
273-
import { useSignal } from '@illuma/react-experimental/signals';
274-
import { CounterService } from './services';
275-
276-
export const Counter = () => {
277-
const service = useDependency(CounterService);
278-
const count = useSignal(service.count);
279-
const double = useSignal(service.double);
280-
281-
return (
282-
<div>
283-
<div>Count: {count}</div>
284-
<div>Double: {double}</div>
285-
<button onClick={() => service.increment()}>Increment</button>
286-
</div>
287-
);
288-
};
289-
```
290-
291-
### Using Signals in React
295+
## Server Rendering
292296

293-
Use the `useSignal` hook to subscribe to signal changes. The component will re-render only when the signal value changes.
297+
Effects never run on a server, so nothing there can own a container's lifetime. Build one
298+
per request and hand it to `IllumaRoot`, which then only publishes it — never bootstraps,
299+
rebuilds, or destroys it.
294300

295301
```tsx
296-
import { useSignal } from '@illuma/react-experimental/signals';
297-
298-
export const Counter = () => {
299-
const value = useSignal(count);
302+
const container = new NodeContainer({ instant: false });
303+
container.provide(requestProviders);
304+
container.bootstrap();
300305

301-
return (
302-
<button onClick={() => count.update((v) => v + 1)}>
303-
Count: {value}
304-
</button>
306+
try {
307+
return renderToString(
308+
<IllumaRoot container={container}>
309+
<App />
310+
</IllumaRoot>,
305311
);
306-
};
312+
} finally {
313+
container.destroy();
314+
}
307315
```
308316

309-
### Linked Signals
317+
No mount happens, so no `onMount` does either: a server render resolves services
318+
but takes none of the resources they own.
310319

311-
`linkedSignal` creates a value that updates when dependencies change but can also be modified manually.
312-
Useful for form state that resets when a selection changes.
313-
314-
```typescript
315-
import { linkedSignal, signal } from '@illuma/react-experimental/signals';
320+
## Testing
316321

317-
const userId = signal(1);
318-
const userForm = linkedSignal(() => ({ id: userId(), name: '' }));
322+
`createTestScope` builds a container the test owns, and returns a wrapper for
323+
`@testing-library/react`. Swapping one token restages the whole graph beneath it.
319324

320-
// Updates when userId changes
321-
userId.set(2);
322-
console.log(userForm().id); // 2
325+
```tsx
326+
import { createTestScope } from '@illuma/react-experimental/testkit';
323327

324-
// Can be modified manually
325-
userForm.update((f) => ({ ...f, name: 'Alice' }));
326-
```
328+
const scope = createTestScope({
329+
providers: [{ provide: ApiService, useClass: FakeApi }],
330+
});
327331

328-
## Integration Example
332+
render(<TodoList />, { wrapper: scope.wrapper });
329333

330-
Combining DI and Signals for efficient state management.
334+
expect(scope.container.get(ApiService)).toBeInstanceOf(FakeApi);
335+
scope.destroy();
336+
```
331337

332-
```typescript
333-
// user.service.ts
334-
import { NodeInjectable } from '@illuma/core';
335-
import { computed, signal } from '@illuma/react-experimental/signals';
338+
The container outlives the tree, so it can still be inspected after an unmount.
336339

337-
@NodeInjectable()
338-
export class UserService {
339-
public readonly user = signal({ name: 'Anonymous' });
340-
public readonly invokeCount = signal(0);
340+
There is no `overrideProvider`: the test scope *is* a root container, and a
341+
container rejects a second provider for a token it already has. Spreading your
342+
application's whole provider list and appending an override will throw. Split
343+
the list instead, so the bindings a test replaces are not in the part it reuses:
341344

342-
public readonly displayName = computed(() =>
343-
`${this.user().name} (Invoked ${this.invokeCount()} times)`,
344-
);
345+
```ts
346+
export const appProviders: Provider[] = [Logger, UserService];
347+
export const platformProviders: Provider[] = [{ provide: ApiService, useClass: HttpApi }];
348+
export const rootProviders: Provider[] = [appProviders, platformProviders];
349+
```
345350

346-
public updateName(name: string) {
347-
this.user.set({ name });
348-
this.invokeCount.update((c) => c + 1);
349-
}
350-
}
351+
```ts
352+
const scope = createTestScope({
353+
providers: [appProviders, { provide: ApiService, useClass: FakeApi }],
354+
});
351355
```
352356

353-
```tsx
354-
// user.component.tsx
355-
import { useDependency } from '@illuma/react-experimental';
356-
import { useSignal } from '@illuma/react-experimental/signals';
357+
## Diagnostics
357358

358-
export const UserBadge = () => {
359-
const service = useDependency(UserService);
360-
const name = useSignal(service.displayName);
359+
Opt in during development to be told which providers nothing ever injected.
361360

362-
return (
363-
<div onClick={() => service.updateName('John')}>
364-
{name}
365-
</div>
366-
);
367-
};
361+
```ts
362+
import { enableReactDiagnostics } from '@illuma/react-experimental';
363+
364+
if (import.meta.env.DEV) enableReactDiagnostics();
368365
```
366+
367+
Output goes through `Illuma.setLogger`, sharing one control surface with the core's own
368+
diagnostics. It is a no-op in production builds.

0 commit comments

Comments
 (0)