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
5 changes: 5 additions & 0 deletions .changeset/pending-server-store-reads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"solid-js": patch
---

Suspend property, symbol, membership, enumeration, and descriptor reads on pending server projections.
36 changes: 24 additions & 12 deletions packages/solid/src/server/signals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1845,9 +1845,9 @@ export function createOptimisticStore<T extends object>(
}

/**
* 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<T extends object>(
state: T,
Expand All @@ -1856,18 +1856,30 @@ function createPendingProxy<T extends object>(
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 [
Expand Down
41 changes: 34 additions & 7 deletions packages/solid/test/server/ssr-async.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3194,40 +3194,52 @@ describe("Async Iterable — createProjection", () => {
expect([...fragmentResults.values()][0]).toBe("<div>Alice</div>");
});

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 () => {
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<unknown>;

createRoot(
() => {
store = createProjection(() => d.promise, { name: "init" });
store = createProjection(() => d.promise, { name: "init", [key]: "init" });
},
{ id: "t" }
);
Expand All @@ -3241,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 () => {
Expand Down
Loading