Skip to content

Commit 4db97c6

Browse files
committed
merge: adversarial review of the adapter
2 parents 5fc4f65 + 6b26fac commit 4db97c6

4 files changed

Lines changed: 409 additions & 6 deletions

File tree

src/container/regressions.spec.tsx

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,15 @@ import {
77
} from "@illuma/core";
88
import { Illuma } from "@illuma/core/plugins";
99
import { render } from "@testing-library/react";
10-
import { Activity } from "react";
10+
import { Activity, StrictMode } from "react";
1111
import { afterEach, describe, expect, it, vi } from "vitest";
1212
import { DiContext } from "./context";
1313
import { __resetReactDiagnostics, enableReactDiagnostics } from "./diagnostics";
1414
import { useDiContainer } from "./hooks/container.hook";
1515
import { useDependency } from "./hooks/dependency.hook";
1616
import { LIFECYCLE_NODE } from "./lifecycle";
1717
import { IllumaRoot, ProviderGroup } from "./provider";
18+
import { ContainerScope, type ScopeOptions } from "./scope";
1819
import { childHookCount, flush } from "./test-utils";
1920

2021
afterEach(() => {
@@ -317,6 +318,97 @@ describe("the providers-changed warning", () => {
317318
});
318319
});
319320

321+
/**
322+
* A scope's container is weakly linked to its parent, which is the only reason
323+
* it is safe to build one during a render React may throw away. The price of
324+
* that link is that a discarded container never runs its destroy hooks, so it
325+
* may only ever hold things that are free to drop.
326+
*/
327+
describe("eager instantiation", () => {
328+
it("never fills a container React might discard", async () => {
329+
let runs = 0;
330+
const TOKEN = new NodeToken<string>("eager-token");
331+
332+
const view = render(
333+
<StrictMode>
334+
<IllumaRoot>
335+
<ProviderGroup
336+
{...({ instant: true } as ScopeOptions)}
337+
providers={[
338+
{
339+
provide: TOKEN,
340+
factory: () => {
341+
runs++;
342+
return "x";
343+
},
344+
},
345+
]}
346+
>
347+
<span>leaf</span>
348+
</ProviderGroup>
349+
</IllumaRoot>
350+
</StrictMode>,
351+
);
352+
await flush();
353+
354+
// The core still runs a scan pass per container to measure the graph; what
355+
// must not happen is a second, real construction nobody will ever destroy.
356+
expect(runs).toBeLessThanOrEqual(2);
357+
358+
view.unmount();
359+
await flush();
360+
expect(runs).toBeLessThanOrEqual(2);
361+
});
362+
});
363+
364+
/**
365+
* A render reads the container, then the deferred release of an earlier unmount
366+
* destroys it, and only then does the commit arrive. React yields between the
367+
* two whenever the update is a transition, so the microtask that destroys the
368+
* container lands squarely in that gap. The rebuild on retain is invisible to
369+
* the render that already ran, which is what the listeners are for.
370+
*/
371+
describe("a release that lands between a render and its commit", () => {
372+
it("tells listeners that the container they read was replaced", async () => {
373+
const scope = ContainerScope.create({});
374+
const read = scope.getContainer();
375+
376+
scope.retain();
377+
scope.release();
378+
await flush();
379+
expect(read.destroyed).toBe(true);
380+
381+
const woken = vi.fn();
382+
scope.subscribe(woken);
383+
384+
scope.retain();
385+
386+
expect(woken).toHaveBeenCalledTimes(1);
387+
expect(scope.getContainer()).not.toBe(read);
388+
expect(scope.getContainer().destroyed).toBe(false);
389+
390+
scope.release();
391+
await flush();
392+
});
393+
394+
it("stays quiet when the container it read is still alive", async () => {
395+
const scope = ContainerScope.create({});
396+
const woken = vi.fn();
397+
scope.subscribe(woken);
398+
399+
scope.retain();
400+
scope.release();
401+
scope.retain();
402+
await flush();
403+
404+
expect(woken).not.toHaveBeenCalled();
405+
expect(scope.getContainer().destroyed).toBe(false);
406+
407+
scope.release();
408+
await flush();
409+
});
410+
});
411+
320412
describe("cross-bundle identity", () => {
321413
it("keys the context and lifecycle token on globalThis so bundles agree", () => {
322414
const g = globalThis as Record<symbol, unknown>;

src/container/scope.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,19 @@ import { reactDiagnosticsEnabled, trackProviderUsage } from "./diagnostics";
77
* The container options a caller may set. `parent` and `weakParentLink` are
88
* withheld because React owns both: the tree decides the parent, and a scope is
99
* only safe to build during a render because the link is weak.
10+
*
11+
* `instant` is withheld because it contradicts that weak link. A weakly linked
12+
* container that React discards is never destroyed — nothing observes that it
13+
* became unreachable — so it may only ever hold what is free to drop. Eager
14+
* instantiation fills a speculative container with live instances whose destroy
15+
* hooks will never run. Build the container yourself and hand it to
16+
* `<IllumaRoot container={...}>` when eager really is what you want; there its
17+
* lifetime is yours and no render can throw it away.
1018
*/
11-
export type ScopeOptions = Omit<iContainerOptions, "parent" | "weakParentLink">;
19+
export type ScopeOptions = Omit<
20+
iContainerOptions,
21+
"parent" | "weakParentLink" | "instant"
22+
>;
1223

1324
export interface iScopeConfig {
1425
readonly parent?: NodeContainer;
@@ -155,8 +166,8 @@ export class ContainerScope {
155166
const { parent, providers, options } = this._config;
156167

157168
const container = new NodeContainer({
158-
instant: false,
159169
...options,
170+
instant: false,
160171
parent,
161172
weakParentLink: true,
162173
});

src/signals/hooks/signal.hook.ts

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,38 @@
11
import { isSignal, type ReadonlySignal } from "@illuma/signals";
2-
import { useSyncExternalStore } from "react";
2+
import { useRef, useSyncExternalStore } from "react";
3+
4+
interface iSignalStore<T> {
5+
readonly signal: ReadonlySignal<T>;
6+
readonly subscribe: (onStoreChange: () => void) => () => void;
7+
readonly getSnapshot: () => T;
8+
}
9+
10+
/**
11+
* `useSyncExternalStore` requires two consecutive `getSnapshot` calls with no
12+
* notification in between to return the very same value, and reading a signal
13+
* does not promise that: `external` re-reads its origin on every access while
14+
* nothing observes it, so a source that hands back a fresh object each time
15+
* yields a different identity per call. React answers that with an infinite
16+
* render loop.
17+
*
18+
* So the value React sees is the last one the signal announced, not whatever a
19+
* read would produce right now. The subscription carries the value with it, and
20+
* subscribing emits the current one straight away, so the cache cannot start out
21+
* behind the signal.
22+
*/
23+
function createStore<T>(signalRef: ReadonlySignal<T>): iSignalStore<T> {
24+
let snapshot = signalRef();
25+
26+
return {
27+
signal: signalRef,
28+
subscribe: (onStoreChange) =>
29+
signalRef.subscribe((value) => {
30+
snapshot = value;
31+
onStoreChange();
32+
}),
33+
getSnapshot: () => snapshot,
34+
};
35+
}
336

437
/**
538
* React hook to subscribe to a signal and get its current value.
@@ -12,8 +45,12 @@ export function useSignal<T>(signalRef: ReadonlySignal<T>): T {
1245
throw new Error("useSignal expects a signal as an argument");
1346
}
1447

48+
const store = useRef<iSignalStore<T> | null>(null);
49+
if (store.current?.signal !== signalRef) store.current = createStore(signalRef);
50+
1551
// A signal is readable synchronously off the server too, so the server
16-
// snapshot is the same read. Without this argument React throws outright
52+
// snapshot is the same getter. Without this argument React throws outright
1753
// during `renderToString`.
18-
return useSyncExternalStore(signalRef.subscribe, signalRef, signalRef);
54+
const { subscribe, getSnapshot } = store.current;
55+
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
1956
}

0 commit comments

Comments
 (0)