Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions packages/solid-signals/src/core/async.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,8 @@ export function handleAsync<T>(

if (isThenable) {
let resolved = false,
rejected = false,
syncError: any,
isSync = true;
(result as PromiseLike<T>).then(
v => {
Expand All @@ -242,11 +244,21 @@ export function handleAsync<T>(
} else asyncWrite(v);
},
e => {
if (!isSync) handleError(e);
if (isSync) {
// Library/cache thenables can invoke the rejection handler
// synchronously during `.then(…)`. Capturing the error here and
// surfacing it after `.then` returns is what lets `<Errored>` see
// it instead of being stuck on the pending path forever (#2764).
syncError = e;
rejected = true;
} else handleError(e);
}
);
isSync = false;
if (!resolved) {
if (rejected) {
handleError(syncError);
throw syncError;
} else if (!resolved) {
globalQueue.initTransition(resolveTransition(el as any));
throw new NotReadyError(context!);
}
Expand Down
40 changes: 29 additions & 11 deletions packages/solid-signals/src/signals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -745,15 +745,33 @@ export function createOptimistic<T>(
*/
export function onSettled(callback: () => void | (() => void)): void {
const owner = getOwner();
owner && !(owner._config & CONFIG_CHILDREN_FORBIDDEN)
? createTrackedEffect(() => untrack(callback), __DEV__ ? { name: "onSettled" } : undefined)
: globalQueue.enqueue(EFFECT_USER, () => {
const cleanup = callback();
if (__DEV__ && cleanup !== undefined && typeof cleanup !== "function") {
throw new Error(
"onSettled callback returned an invalid cleanup value. Return a cleanup function or undefined."
);
}
cleanup?.();
});
if (owner && !(owner._config & CONFIG_CHILDREN_FORBIDDEN)) {
createTrackedEffect(() => untrack(callback), __DEV__ ? { name: "onSettled" } : undefined);
return;
}
// Fallback path: either no owner, or the current owner is a children-
// forbidden scope (the most common case is `onSettled()` called from
// inside another `onSettled()` callback, which itself runs as a
// tracked effect). The original implementation invoked the returned
// cleanup eagerly inside the same flush, which immediately tore down
// setup helpers (#2766). Defer cleanup via the captured owner's
// disposal chain so it fires on real disposal, not on settle.
const capturedOwner = owner;
globalQueue.enqueue(EFFECT_USER, () => {
const result = callback();
if (__DEV__ && result !== undefined && typeof result !== "function") {
throw new Error(
"onSettled callback returned an invalid cleanup value. Return a cleanup function or undefined."
);
}
if (typeof result === "function") {
if (capturedOwner) {
runWithOwner(capturedOwner, () => cleanup(result as Disposable));
} else {
// No owner to scope the cleanup to — the helper is genuinely
// orphaned, so we have no point at which to fire it. Drop it
// rather than run it immediately and break the setup contract.
}
}
});
}
27 changes: 27 additions & 0 deletions packages/solid-signals/tests/onSettled.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,3 +224,30 @@ it("should call cleanup on re-run after async settles", async () => {
// Now callback runs
expect(effect).toHaveBeenCalledWith("done");
});

it("defers cleanup from nested onSettled to owner disposal (#2766)", () => {
// Helper that uses onSettled internally to install + tear down a
// subscription. The parent calls the helper from inside another
// onSettled — under the bug, the helper's cleanup was invoked eagerly
// in the same flush, immediately tearing down the subscription.
const log: string[] = [];
function useSubscription() {
onSettled(() => {
log.push("subscribed");
return () => log.push("unsubscribed");
});
}

const dispose = createRoot(stop => {
onSettled(() => {
useSubscription();
});
return stop;
});

flush();
expect(log).toEqual(["subscribed"]);

dispose();
expect(log).toEqual(["subscribed", "unsubscribed"]);
});
34 changes: 34 additions & 0 deletions packages/solid-signals/tests/syncThenable.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
createErrorBoundary,
createMemo,
createRenderEffect,
createRoot,
Expand Down Expand Up @@ -515,3 +516,36 @@ describe("fromObservable pattern", () => {
expect(signal()).toBe(42);
});
});

describe("sync rejecting thenable", () => {
// Helper: a thenable that calls its rejection handler synchronously
function syncRejectingThenable(reason: unknown): PromiseLike<never> {
return {
then<R1, R2 = never>(
_onfulfilled?: ((v: never) => R1 | PromiseLike<R1>) | null,
onrejected?: ((reason: any) => R2 | PromiseLike<R2>) | null
): PromiseLike<R1 | R2> {
try {
const result = onrejected ? onrejected(reason) : (reason as any);
return syncThenable(result) as PromiseLike<R1 | R2>;
} catch (err) {
return syncRejectingThenable(err) as PromiseLike<R1 | R2>;
}
}
};
}

it("reaches Errored on synchronous rejection (#2764)", () => {
const error = new Error("sync-reject");
const result = createRoot(() =>
createErrorBoundary(
() => {
const value = createMemo(() => syncRejectingThenable(error));
return value();
},
(err: () => unknown) => `caught: ${(err() as Error).message}`
)
);
expect(result()).toBe("caught: sync-reject");
});
});