Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .changeset/server-projection-errors.md
Original file line number Diff line number Diff line change
@@ -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.
42 changes: 25 additions & 17 deletions packages/solid/src/server/signals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1852,16 +1852,20 @@ export function createOptimisticStore<T extends object>(
function createPendingProxy<T extends object>(
state: T,
source: Promise<any>
): [proxy: Store<T>, markReady: (frozenState?: T) => void] {
let pending = true;
): [proxy: Store<T>, 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);
}
Expand All @@ -1870,7 +1874,11 @@ function createPendingProxy<T extends object>(
proxy as Store<T>,
(frozen?: T) => {
if (frozen) readTarget = frozen;
pending = false;
status = 1;
},
(reason: any) => {
error = reason;
status = 2;
}
];
}
Expand Down Expand Up @@ -2025,7 +2033,7 @@ export function createProjection<T extends object>(
if (!(error instanceof NotReadyError)) throw error;

const deferred = createDeferredPromise<T>();
const [pending, markReady] = createPendingProxy(state, deferred.promise);
const [pending, markReady, markError] = createPendingProxy(state, deferred.promise);
seedLock(markReady);
settleServerAsync<void | T, T>(
Promise.reject(error),
Expand All @@ -2038,8 +2046,8 @@ export function createProjection<T extends object>(
markReady();
return state as T;
},
(_error: any) => {
markReady();
(error: any) => {
markError(error);
},
() => disposed
);
Expand All @@ -2056,7 +2064,7 @@ export function createProjection<T extends object>(
let currentResult = result;
let iter: AsyncIterator<void | T>;
const deferred = createDeferredPromise<T>();
const [pending, markReady] = createPendingProxy(state, deferred.promise);
const [pending, markReady, markError] = createPendingProxy(state, deferred.promise);
seedLock(markReady);
const runFirst = () => {
const source = currentResult ?? runProjection();
Expand All @@ -2083,7 +2091,7 @@ export function createProjection<T extends object>(
return state as T;
},
(error: any) => {
markReady();
markError(error);
},
() => disposed
);
Expand All @@ -2098,7 +2106,7 @@ export function createProjection<T extends object>(
let iter: AsyncIterator<void | T>;
let firstResult: IteratorResult<void | T> | undefined;
const deferred = createDeferredPromise<void>();
const [pending, markReady] = createPendingProxy(state, deferred.promise);
const [pending, markReady, markError] = createPendingProxy(state, deferred.promise);
seedLock(markReady);
const runFirst = () => {
const source = currentResult ?? runProjection();
Expand Down Expand Up @@ -2137,7 +2145,7 @@ export function createProjection<T extends object>(
return undefined;
},
(error: any) => {
markReady();
markError(error);
},
() => disposed
);
Expand Down Expand Up @@ -2222,7 +2230,7 @@ export function createProjection<T extends object>(

if (isThenable<T>(result)) {
const deferred = createDeferredPromise<T>();
const [pending, markReady] = createPendingProxy(state, deferred.promise);
const [pending, markReady, markError] = createPendingProxy(state, deferred.promise);
seedLock(markReady);
settleServerAsync(
result,
Expand All @@ -2236,7 +2244,7 @@ export function createProjection<T extends object>(
return state as T;
},
(error: any) => {
markReady();
markError(error);
},
() => disposed
);
Expand Down
112 changes: 112 additions & 0 deletions packages/solid/test/server/ssr-async.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>;

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<void>();
const error = new Error("projection failed");
let store: any;
let source!: Promise<unknown>;

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<void>();
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;
Expand Down