Skip to content

Atom.runtime: seeding runtime.layer with a test Layer drops Reactivity and addGlobalLayer layers, so reactivityKeys mutations die #9300

Closed

Description

@nick-inkeep

What version of Effect is running?

4.0.5 (same code on main at 34ec440)

What steps can reproduce the bug?

Seeding an Atom.runtime's layer through registry initialValues also removes the runtime's Reactivity service and every layer added with addGlobalLayer. This is how the existing Atom.test.ts "runtime replacement" and atom-react "can inject test layers" tests inject test services. The seed only has to match the declared type Layer<R, ER>, so it type-checks. After that:

  • a runtime.fn(…, { reactivityKeys }) mutation dies;
  • so does any runtime atom whose effect uses Reactivity;
  • services from global layers are missing.

These cases, added to packages/effect/test/reactivity/Atom.test.ts (they use its Counter fixtures), fail on main; the two controls pass:

describe("runtime replacement keeps the runtime's own layers", () => {
  const count = counterRuntime.atom(Counter.use((_) => _.get)).pipe(Atom.withReactivity(["counter"]))
  const inc = counterRuntime.fn(() => Counter.use((_) => _.inc), { reactivityKeys: ["counter"] })

  it("reactivityKeys mutation, no replacement (control)", () => {
    const r = AtomRegistry.make()
    r.mount(count)
    expect(r.get(count)).toEqual(AsyncResult.success(1))
    r.set(inc, void 0)
    expect(r.get(inc)).toEqual(AsyncResult.success(undefined))
    expect(r.get(count)).toEqual(AsyncResult.success(2))
    r.dispose()
  })

  it("reactivityKeys mutation, replaced layer", () => {
    const r = AtomRegistry.make({
      initialValues: [Atom.initialValue(counterRuntime.layer, CounterTest)]
    })
    r.mount(count)
    expect(r.get(count)).toEqual(AsyncResult.success(10))
    r.set(inc, void 0)
    expect(r.get(inc)).toEqual(AsyncResult.success(undefined))
    expect(r.get(count)).toEqual(AsyncResult.success(11))
    r.dispose()
  })

  const Global = Context.Service<string>("Atom.test/GlobalLayerService")
  const factory = Atom.context()
  factory.addGlobalLayer(Layer.succeed(Global, "global"))
  const runtime = factory(CounterLayer)
  const global = runtime.atom(Effect.serviceOption(Global))

  it("addGlobalLayer, no replacement (control)", () => {
    const r = AtomRegistry.make()
    expect(r.get(global)).toEqual(AsyncResult.success(Option.some("global")))
    r.dispose()
  })

  it("addGlobalLayer, replaced layer", () => {
    const r = AtomRegistry.make({
      initialValues: [Atom.initialValue(runtime.layer, CounterTest)]
    })
    expect(r.get(global)).toEqual(AsyncResult.success(Option.some("global")))
    r.dispose()
  })
})

What is the expected behavior?

The seed replaces only the layer passed to Atom.runtime, which is what the type of runtime.layer, Atom<Layer<R, ER>>, describes. The runtime keeps providing:

  • Reactivity, which the types of runtime.atom and runtime.fn let their effects require;
  • the global layers.

The mutation succeeds and count refreshes to 11. The global service is Some("global").

What do you see instead?

  • reactivityKeys mutation, replaced layer: r.get(inc) is a Failure with Die(Error: Service not found: effect/reactivity/Reactivity).
  • addGlobalLayer, replaced layer: Effect.serviceOption(Global) is None.

Putting Reactivity.layer itself into the seed avoids the defect, because the memo map shares that instance with the one withReactivity registers on. A Reactivity built from any other Layer value lets the mutation succeed, but withReactivity atoms then never refresh. The types point to neither requirement.

Additional information

The atom exposed as runtime.layer holds Layer.provideMerge(create, globalLayer), not create (Atom.ts#L801-L806):

  • globalLayer starts as Reactivity.layer (#L789), and addGlobalLayer merges into it (#L817-L819).
  • read builds whatever the atom holds (#L808-L812), so a seed replaces the merged value. Yet layer is declared Atom<Layer.Layer<R, ER>> (#L640).
  • reactivityKeys wraps the function's effect in Reactivity.mutation (#L346-L351).

A seed would replace only the user's layer if the layer atom kept create and read applied Layer.provideMerge(…, globalLayer). main has the same code. Its only change to read since 4.0.5 is about logging finalizer failures.

Activity

  1. self-assigned this
    on Oct 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions