@@ -7,14 +7,15 @@ import {
77} from "@illuma/core" ;
88import { Illuma } from "@illuma/core/plugins" ;
99import { render } from "@testing-library/react" ;
10- import { Activity } from "react" ;
10+ import { Activity , StrictMode } from "react" ;
1111import { afterEach , describe , expect , it , vi } from "vitest" ;
1212import { DiContext } from "./context" ;
1313import { __resetReactDiagnostics , enableReactDiagnostics } from "./diagnostics" ;
1414import { useDiContainer } from "./hooks/container.hook" ;
1515import { useDependency } from "./hooks/dependency.hook" ;
1616import { LIFECYCLE_NODE } from "./lifecycle" ;
1717import { IllumaRoot , ProviderGroup } from "./provider" ;
18+ import { ContainerScope , type ScopeOptions } from "./scope" ;
1819import { childHookCount , flush } from "./test-utils" ;
1920
2021afterEach ( ( ) => {
@@ -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+
320412describe ( "cross-bundle identity" , ( ) => {
321413 it ( "keys the context and lifecycle token on globalThis so bundles agree" , ( ) => {
322414 const g = globalThis as Record < symbol , unknown > ;
0 commit comments