From 1f2c59a1b6370cf03be54705fb5322325dfea40a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 27 Jul 2026 11:04:04 -0400 Subject: [PATCH] fix(core): commit state before finalize publishes (#38983) --- packages/core/src/state.ts | 9 ++++++--- packages/core/test/state.test.ts | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/packages/core/src/state.ts b/packages/core/src/state.ts index 324aac83cb7..58230da627a 100644 --- a/packages/core/src/state.ts +++ b/packages/core/src/state.ts @@ -59,7 +59,11 @@ export interface Options { readonly initial: () => State /** Wraps mutable state in a domain-specific draft API. */ readonly draft: MakeDraft - /** Runs after all active transforms and before the rebuilt state becomes visible. */ + /** + * Runs after the rebuilt state becomes visible. Update events published here + * act as read barriers: subscribers refetching on the event observe the + * committed state. + */ readonly finalize?: (draft: DraftApi) => Effect.Effect } @@ -81,9 +85,8 @@ export function create(options: Options): Inte const semaphore = Semaphore.makeUnsafe(1) const commit = Effect.fn("State.commit")(function* (next: State) { - const api = options.draft(next) - if (options.finalize) yield* options.finalize(api) state = next + if (options.finalize) yield* options.finalize(options.draft(next)) }) const apply = (transform: TransformCallback, draft: DraftApi) => diff --git a/packages/core/test/state.test.ts b/packages/core/test/state.test.ts index f2120356c6c..39163a18d65 100644 --- a/packages/core/test/state.test.ts +++ b/packages/core/test/state.test.ts @@ -36,6 +36,25 @@ describe("State", () => { }), ) + it.effect("commits rebuilt state before finalize runs", () => + Effect.gen(function* () { + const observed: string[][] = [] + const state: State.Interface<{ values: string[] }, { add: (item: string) => void }> = State.create({ + initial: () => ({ values: [] as string[] }), + draft: (draft) => ({ add: (item: string) => draft.values.push(item) }), + finalize: () => Effect.sync(() => observed.push([...state.get().values])), + }) + + yield* state.transform((draft) => { + draft.add("value") + }) + + // Update events publish from finalize, so consumers reading on the event + // must observe the rebuilt state, not the previous one. + expect(observed).toEqual([["value"]]) + }), + ) + it.effect("runs transforms during every reload", () => Effect.gen(function* () { let value = "first"