diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index feb5f0b3ea41e7abe5ceeb2037d76b79f4456f46..a700f4de1032bfc56a0af21f45ccc2514a1abbbd 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -115,7 +115,7 @@ function createPullRequestRefreshAtomFamily( }); } -/** Refresh only the live fields a linked thread renders. */ +/** Refresh only the live fields a linked thread renders. A merged pull request is not re-read. */ export function createLinkedPullRequestSummaryAtomFamily( runtime: Atom.AtomRuntime, refreshes = createPullRequestRefreshAtomFamily(runtime), @@ -129,6 +129,7 @@ export function createLinkedPullRequestSummaryAtomFamily( refreshIntervalMs: 60_000, idleTtlMs: LINKED_PULL_REQUEST_IDLE_TTL_MS, refreshTrigger: ({ environmentId }) => refreshes({ environmentId, input: {} }), + isFinal: (summary) => summary.state === "merged", }); } diff --git a/packages/client-runtime/src/state/runtime.test.ts b/packages/client-runtime/src/state/runtime.test.ts index 0cc360288c20ba01b5ca65a26b461b5ca3b525e9..5bb3ed6801bc643d2bf82d864e78349c5bb7add3 100644 --- a/packages/client-runtime/src/state/runtime.test.ts +++ b/packages/client-runtime/src/state/runtime.test.ts @@ -88,8 +88,10 @@ const makeEnvironmentQueryHarness = Effect.fn("TestEnvironmentQuery.makeHarness" idleTtlMs, persistence, initialState, + isFinal, }: { readonly refreshTrigger?: Atom.Atom; + readonly isFinal?: (value: A) => boolean; readonly idleTtlMs?: number; readonly persistence?: QueryPersistence; readonly initialState?: SupervisorConnectionState; @@ -127,6 +129,7 @@ const makeEnvironmentQueryHarness = Effect.fn("TestEnvironmentQuery.makeHarness" execute: () => execute, ...(refreshTrigger === undefined ? {} : { refreshTrigger: () => refreshTrigger }), ...(idleTtlMs === undefined ? {} : { idleTtlMs }), + ...(isFinal === undefined ? {} : { isFinal }), }); return { @@ -675,6 +678,30 @@ describe("environment query lifecycle", () => { ), ); + it.effect("stops refreshing on its trigger once the value is final", () => + Effect.scoped( + Effect.gen(function* () { + let executions = 0; + const trigger = Atom.make(0).pipe(Atom.keepAlive); + const harness = yield* makeEnvironmentQueryHarness( + Effect.sync(() => (executions += 1)), + { refreshTrigger: trigger, isFinal: (value) => value >= 2 }, + ); + const registry = AtomRegistry.make(); + yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose())); + const read = readEnvironmentQuery(registry, harness.atom); + + registry.mount(harness.atom); + expect(yield* read).toBe(1); + registry.set(trigger, 1); + expect(yield* read).toBe(2); + registry.set(trigger, 2); + expect(yield* read).toBe(2); + expect(executions).toBe(2); + }), + ), + ); + it.effect("keeps an idle query when its trigger recomputes to the same value", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts index 6995c0e989584c7bdc9a45cb87bf79c21d875496..95aabd6d843da0e3c3b8507a45adad09d5b7b7a9 100644 --- a/packages/client-runtime/src/state/runtime.ts +++ b/packages/client-runtime/src/state/runtime.ts @@ -74,6 +74,8 @@ interface EnvironmentQueryAtomOptions extends Omit< readonly environmentId: EnvironmentIdType; readonly input: Input; }) => Atom.Atom | undefined; + /** A value that can no longer change. It stops the interval and `refreshTrigger` re-reads. */ + readonly isFinal?: (value: A) => boolean; } interface EnvironmentSubscriptionAtomOptions { @@ -485,8 +487,8 @@ function runInEnvironment( * refreshes on its next read. */ const refreshOnSignalWhileRead = - (signal: Atom.Atom) => - (self: Atom.Atom): Atom.Atom => { + (signal: Atom.Atom, isFinal: (value: V) => boolean) => + (self: Atom.Atom): Atom.Atom => { // The signal value each registry's data was read under. Held outside the node because the // registry can sweep this node while `self` keeps the data, and the rebuilt node must still // see a change it missed. @@ -499,13 +501,31 @@ const refreshOnSignalWhileRead = const changed = readUnder.has(get.registry) && !Object.is(readUnder.get(get.registry), current); readUnder.set(get.registry, current); - if (changed) get.refresh(self); + if (changed && !isFinal(get.once(self))) get.refresh(self); return get.once(self); }, { initialValueTarget: self }, ); }; +/** `Atom.withRefresh`, except the timer stops once the value is final. */ +const refreshEveryUntilFinal = + (intervalMs: number, isFinal: (value: V) => boolean) => + (self: Atom.Atom): Atom.Atom => + Atom.transform( + self, + (get) => { + const value = get(self); + if (!isFinal(value)) { + // @effect-diagnostics-next-line globalTimers:off - Mirrors `Atom.withRefresh`, outside an Effect runtime. + const handle = setTimeout(() => get.refresh(self), intervalMs); + get.addFinalizer(() => clearTimeout(handle)); + } + return value; + }, + { initialValueTarget: self }, + ); + export function createEnvironmentQueryAtomFamily( runtime: Atom.AtomRuntime, options: EnvironmentQueryAtomOptions< @@ -626,15 +646,17 @@ export function createEnvironmentQueryAtomFamily( }), Atom.setIdleTTL(idleTtlMs), ); + const isFinal = (result: AsyncResult.AsyncResult) => + options.isFinal !== undefined && Option.exists(AsyncResult.value(result), options.isFinal); const intervalQuery = options.refreshIntervalMs === undefined ? queryAtom - : queryAtom.pipe(Atom.withRefresh(options.refreshIntervalMs)); + : queryAtom.pipe(refreshEveryUntilFinal(options.refreshIntervalMs, isFinal)); const refreshTrigger = options.refreshTrigger?.(target); return ( refreshTrigger === undefined ? intervalQuery - : intervalQuery.pipe(refreshOnSignalWhileRead(refreshTrigger)) + : intervalQuery.pipe(refreshOnSignalWhileRead(refreshTrigger, isFinal)) ).pipe(Atom.setIdleTTL(idleTtlMs), Atom.withLabel(`${options.label}:${key}`)); }); return (target) => family(environmentRpcKey(target)); @@ -716,6 +738,7 @@ export function createEnvironmentRpcQueryAtomFamily< readonly environmentId: EnvironmentIdType; readonly input: EnvironmentRpcInput; }) => Atom.Atom | undefined; + readonly isFinal?: (value: EnvironmentRpcSuccess) => boolean; }, ) { return createEnvironmentQueryAtomFamily(runtime, { @@ -727,6 +750,7 @@ export function createEnvironmentRpcQueryAtomFamily< ? {} : { refreshIntervalMs: options.refreshIntervalMs }), ...(options.refreshTrigger === undefined ? {} : { refreshTrigger: options.refreshTrigger }), + ...(options.isFinal === undefined ? {} : { isFinal: options.isFinal }), execute: ( input: EnvironmentRpcInput, emit: (value: EnvironmentRpcSuccess) => Effect.Effect,