From 8d1ba82f6e2dd42a7d87572563bbda2204d38011 Mon Sep 17 00:00:00 2001 From: Gabriel Valfridsson Date: Wed, 2 Sep 2026 17:37:29 +0200 Subject: [PATCH 1/2] fix(solid): gate pending projection reads --- .changeset/pending-server-store-reads.md | 5 +++ packages/solid/src/server/signals.ts | 36 +++++++++++++------- packages/solid/test/server/ssr-async.spec.ts | 19 ++++++++--- 3 files changed, 44 insertions(+), 16 deletions(-) create mode 100644 .changeset/pending-server-store-reads.md diff --git a/.changeset/pending-server-store-reads.md b/.changeset/pending-server-store-reads.md new file mode 100644 index 000000000..c00832347 --- /dev/null +++ b/.changeset/pending-server-store-reads.md @@ -0,0 +1,5 @@ +--- +"solid-js": patch +--- + +Suspend property, symbol, membership, enumeration, and descriptor reads on pending server projections. diff --git a/packages/solid/src/server/signals.ts b/packages/solid/src/server/signals.ts index d49b9b08e..428de607d 100644 --- a/packages/solid/src/server/signals.ts +++ b/packages/solid/src/server/signals.ts @@ -1845,9 +1845,9 @@ export function createOptimisticStore( } /** - * Wraps a store in a Proxy that throws NotReadyError on property reads - * while the async data is pending. Once markReady() is called, reads - * pass through to the underlying state. + * Wraps a store in a Proxy that throws NotReadyError on property and + * structural reads while the async data is pending. Once markReady() is + * called, reads pass through to the underlying state. */ function createPendingProxy( state: T, @@ -1856,18 +1856,30 @@ function createPendingProxy( let status: 0 | 1 | 2 = 0; let error: any; let readTarget: T = state; + const gate = () => { + if (status === 1) return; + if (status === 2) throw error; + // 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); + }; const proxy = new Proxy(state, { get(obj, key, receiver) { - 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); - } - } + gate(); return Reflect.get(readTarget, key); + }, + has(obj, key) { + gate(); + return Reflect.has(obj, key); + }, + ownKeys(obj) { + gate(); + return Reflect.ownKeys(obj); + }, + getOwnPropertyDescriptor(obj, key) { + gate(); + return Reflect.getOwnPropertyDescriptor(obj, key); } }); return [ diff --git a/packages/solid/test/server/ssr-async.spec.ts b/packages/solid/test/server/ssr-async.spec.ts index 06b2830a6..bc9cba03b 100644 --- a/packages/solid/test/server/ssr-async.spec.ts +++ b/packages/solid/test/server/ssr-async.spec.ts @@ -3194,26 +3194,37 @@ describe("Async Iterable — createProjection", () => { expect([...fragmentResults.values()][0]).toBe("
Alice
"); }); - test("Promise projection throws NotReadyError until resolved", async () => { + test("Promise projection reads throw NotReadyError until resolved", async () => { const { context } = createStreamTrackingContext(); sharedConfig.context = context; - const d = deferred<{ name: string }>(); + const key = Symbol("status"); + const d = deferred<{ name: string; [key]: string }>(); let store: any; createRoot( () => { - store = createProjection(() => d.promise, { name: "init" }); + store = createProjection(() => d.promise, { name: "init", [key]: "init" }); }, { id: "t" } ); expect(() => store.name).toThrow(NotReadyError); + expect(() => store[key]).toThrow(NotReadyError); + expect(() => "name" in store).toThrow(NotReadyError); + expect(() => Object.keys(store)).toThrow(NotReadyError); + expect(() => Object.getOwnPropertyDescriptor(store, "name")).toThrow(NotReadyError); + expect(() => Object.hasOwn(store, "name")).toThrow(NotReadyError); - d.resolve({ name: "resolved" }); + d.resolve({ name: "resolved", [key]: "ready" }); await tick(); expect(store.name).toBe("resolved"); + expect(store[key]).toBe("ready"); + expect("name" in store).toBe(true); + expect(Object.keys(store)).toEqual(["name"]); + expect(Object.getOwnPropertyDescriptor(store, "name")?.value).toBe("resolved"); + expect(Object.hasOwn(store, "name")).toBe(true); }); test("Promise projection preserves its error after rejection", async () => { From cd654cacfce786c716a5c49f6ba4fad409d909e4 Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Wed, 2 Sep 2026 10:40:28 -0700 Subject: [PATCH 2/2] test(solid): cover rejected projection membranes Ensure every gated read path preserves and rethrows the original projection error after settlement. Co-authored-by: Cursor --- packages/solid/test/server/ssr-async.spec.ts | 22 +++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/solid/test/server/ssr-async.spec.ts b/packages/solid/test/server/ssr-async.spec.ts index bc9cba03b..f47313a91 100644 --- a/packages/solid/test/server/ssr-async.spec.ts +++ b/packages/solid/test/server/ssr-async.spec.ts @@ -3231,14 +3231,15 @@ describe("Async Iterable — createProjection", () => { const { context } = createStreamTrackingContext(); sharedConfig.context = context; - const d = deferred<{ name: string }>(); + const key = Symbol("status"); + const d = deferred<{ name: string; [key]: string }>(); const error = new Error("projection failed"); let store: any; let source!: Promise; createRoot( () => { - store = createProjection(() => d.promise, { name: "init" }); + store = createProjection(() => d.promise, { name: "init", [key]: "init" }); }, { id: "t" } ); @@ -3252,7 +3253,22 @@ describe("Async Iterable — createProjection", () => { d.reject(error); await expect(source).rejects.toBe(error); - expect(() => store.name).toThrow(error); + for (const read of [ + () => store.name, + () => store[key], + () => "name" in store, + () => Object.keys(store), + () => Object.getOwnPropertyDescriptor(store, "name"), + () => Object.hasOwn(store, "name") + ]) { + let thrown: unknown; + try { + read(); + } catch (reason) { + thrown = reason; + } + expect(thrown).toBe(error); + } }); test("async iterable projection preserves a rejection before its first yield", async () => {