diff --git a/.changeset/server-projection-errors.md b/.changeset/server-projection-errors.md new file mode 100644 index 000000000..b2f44bced --- /dev/null +++ b/.changeset/server-projection-errors.md @@ -0,0 +1,6 @@ +--- +"solid-js": patch +--- + +Keep ordinary property reads on rejected server projections throwing the original error instead +of exposing seed values, while preserving existing reflection and symbol behavior. diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts index c27c438a2..d49b9b08e 100644 --- a/packages/solid/src/server/signals.ts +++ b/packages/solid/src/server/signals.ts @@ -1852,16 +1852,20 @@ export function createOptimisticStore( function createPendingProxy( state: T, source: Promise -): [proxy: Store, markReady: (frozenState?: T) => void] { - let pending = true; +): [proxy: Store, markReady: (frozenState?: T) => void, markError: (error: any) => void] { + let status: 0 | 1 | 2 = 0; + let error: any; let readTarget: T = state; const proxy = new Proxy(state, { get(obj, key, receiver) { - if (pending && typeof key !== "symbol") { - // Bare client store: same loud-outside-a-boundary rule as the memo - // read path (see clientHoleRead). - if (source === CLIENT_HOLE) clientHoleRead(); - throw new NotReadyError(source); + if (typeof key !== "symbol") { + if (status === 2) throw error; + if (status === 0) { + // Bare client store: same loud-outside-a-boundary rule as the memo + // read path (see clientHoleRead). + if (source === CLIENT_HOLE) clientHoleRead(); + throw new NotReadyError(source); + } } return Reflect.get(readTarget, key); } @@ -1870,7 +1874,11 @@ function createPendingProxy( proxy as Store, (frozen?: T) => { if (frozen) readTarget = frozen; - pending = false; + status = 1; + }, + (reason: any) => { + error = reason; + status = 2; } ]; } @@ -2025,7 +2033,7 @@ export function createProjection( if (!(error instanceof NotReadyError)) throw error; const deferred = createDeferredPromise(); - const [pending, markReady] = createPendingProxy(state, deferred.promise); + const [pending, markReady, markError] = createPendingProxy(state, deferred.promise); seedLock(markReady); settleServerAsync( Promise.reject(error), @@ -2038,8 +2046,8 @@ export function createProjection( markReady(); return state as T; }, - (_error: any) => { - markReady(); + (error: any) => { + markError(error); }, () => disposed ); @@ -2056,7 +2064,7 @@ export function createProjection( let currentResult = result; let iter: AsyncIterator; const deferred = createDeferredPromise(); - const [pending, markReady] = createPendingProxy(state, deferred.promise); + const [pending, markReady, markError] = createPendingProxy(state, deferred.promise); seedLock(markReady); const runFirst = () => { const source = currentResult ?? runProjection(); @@ -2083,7 +2091,7 @@ export function createProjection( return state as T; }, (error: any) => { - markReady(); + markError(error); }, () => disposed ); @@ -2098,7 +2106,7 @@ export function createProjection( let iter: AsyncIterator; let firstResult: IteratorResult | undefined; const deferred = createDeferredPromise(); - const [pending, markReady] = createPendingProxy(state, deferred.promise); + const [pending, markReady, markError] = createPendingProxy(state, deferred.promise); seedLock(markReady); const runFirst = () => { const source = currentResult ?? runProjection(); @@ -2137,7 +2145,7 @@ export function createProjection( return undefined; }, (error: any) => { - markReady(); + markError(error); }, () => disposed ); @@ -2222,7 +2230,7 @@ export function createProjection( if (isThenable(result)) { const deferred = createDeferredPromise(); - const [pending, markReady] = createPendingProxy(state, deferred.promise); + const [pending, markReady, markError] = createPendingProxy(state, deferred.promise); seedLock(markReady); settleServerAsync( result, @@ -2236,7 +2244,7 @@ export function createProjection( return state as T; }, (error: any) => { - markReady(); + markError(error); }, () => disposed ); diff --git a/packages/solid/test/server/ssr-async.spec.ts b/packages/solid/test/server/ssr-async.spec.ts index bb922d7c4..06b2830a6 100644 --- a/packages/solid/test/server/ssr-async.spec.ts +++ b/packages/solid/test/server/ssr-async.spec.ts @@ -3216,6 +3216,118 @@ describe("Async Iterable — createProjection", () => { expect(store.name).toBe("resolved"); }); + test("Promise projection preserves its error after rejection", async () => { + const { context } = createStreamTrackingContext(); + sharedConfig.context = context; + + const d = deferred<{ name: string }>(); + const error = new Error("projection failed"); + let store: any; + let source!: Promise; + + createRoot( + () => { + store = createProjection(() => d.promise, { name: "init" }); + }, + { id: "t" } + ); + + try { + store.name; + } catch (error) { + expect(error).toBeInstanceOf(NotReadyError); + source = (error as NotReadyError).source; + } + + d.reject(error); + await expect(source).rejects.toBe(error); + expect(() => store.name).toThrow(error); + }); + + test("async iterable projection preserves a rejection before its first yield", async () => { + const { context } = createStreamTrackingContext(); + sharedConfig.context = context; + + const d = deferred(); + const error = new Error("projection failed"); + let store: any; + let source!: Promise; + + createRoot( + () => { + store = createProjection( + async function* () { + await d.promise; + yield { name: "resolved", count: 1 }; + }, + { name: "init", count: 0 } + ); + }, + { id: "t" } + ); + + try { + store.name; + } catch (error) { + expect(error).toBeInstanceOf(NotReadyError); + source = (error as NotReadyError).source; + } + + d.reject(error); + await expect(source).rejects.toBe(error); + + for (const read of [() => store.name, () => store.count, () => store.name]) { + let thrown: unknown; + try { + read(); + } catch (error) { + thrown = error; + } + expect(thrown).toBe(error); + } + }); + + test("seedLoadingValue projection preserves a rejected first result", async () => { + const { context, serializeLog } = createStreamTrackingContext(); + sharedConfig.context = context; + + const d = deferred(); + const error = new Error("seeded projection failed"); + let store: any; + + createRoot( + () => { + store = createProjection( + async function* () { + await d.promise; + yield { name: "resolved", count: 1 }; + }, + { name: "seed", count: 0 }, + { seedLoadingValue: true } + ); + }, + { id: "t" } + ); + + expect(store.name).toBe("seed"); + expect(store.count).toBe(0); + + const iter = serializeLog[0].value[Symbol.asyncIterator](); + const first = iter.next(); + d.reject(error); + await expect(first).rejects.toBe(error); + + for (const read of [() => store.name, () => store.count, () => store.name]) { + let thrown: unknown; + try { + read(); + } catch (error) { + thrown = error; + } + expect(thrown).toBe(error); + } + }); + test("sync projection does NOT throw NotReadyError", () => { const { context } = createStreamTrackingContext(); sharedConfig.context = context;