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/fast-plants-dress.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"solid-relay": minor
---

fix: correctly and fully SSR on deferred fragments
11 changes: 10 additions & 1 deletion src/RelayEnvironment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { IEnvironment } from "relay-runtime";
import {
type Accessor,
type JSXElement,
type Resource,
createComponent,
createContext,
createMemo,
Expand All @@ -16,14 +17,15 @@ interface Props {

const RelayContext = createContext<{
environment: Accessor<IEnvironment>;
dataStores: WeakMap<Resource<unknown>, unknown>;
}>();

export function RelayEnvironmentProvider(props: Props): JSXElement {
const environment = createMemo(() => props.environment);

return createComponent(RelayContext.Provider, {
get value() {
return { environment };
return { environment, dataStores: new WeakMap() };
},
get children() {
return props.children;
Expand All @@ -46,3 +48,10 @@ export function useRelayEnvironment(): () => IEnvironment {

return context.environment;
}

export function useDataStores():
| WeakMap<Resource<unknown>, unknown>
| undefined {
const context = useContext(RelayContext);
return context?.dataStores;
}
175 changes: 98 additions & 77 deletions src/primitives/createFragment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,16 @@
GraphQLResponse,
GraphQLTaggedNode,
Subscribable,
Subscription,
} from "relay-runtime";
import { observeFragment } from "relay-runtime/experimental.js";
import {
batch,
createComputed,
createMemo,
createResource,
onCleanup,
} from "solid-js";
import { batch, createResource, createSignal, untrack } from "solid-js";
import type { Accessor } from "solid-js";
import { createStore, reconcile, unwrap } from "solid-js/store";
import { type SetStoreFunction, reconcile, unwrap } from "solid-js/store";
import { isServer } from "solid-js/web";
import { useRelayEnvironment } from "../RelayEnvironment";
import type { KeyType, KeyTypeData } from "../types/keyType";
import { type DataProxy, makeDataProxy } from "../utils/dataProxy";
import { type DataStore, createDataStore } from "../utils/dataStore";

type FragmentResult<T> =
| {
Expand All @@ -37,15 +33,15 @@
export function createFragment<TKey extends KeyType>(
fragment: GraphQLTaggedNode,
key: Accessor<TKey>,
): DataProxy<KeyTypeData<TKey>>;
): DataStore<KeyTypeData<TKey>>;
export function createFragment<TKey extends KeyType>(
fragment: GraphQLTaggedNode,
key: Accessor<TKey | null | undefined>,
): DataProxy<KeyTypeData<TKey> | null | undefined>;
): DataStore<KeyTypeData<TKey> | null | undefined>;
export function createFragment<TKey extends KeyType>(
fragment: GraphQLTaggedNode,
key: Accessor<TKey | null | undefined>,
): DataProxy<KeyTypeData<TKey> | null | undefined> {
): DataStore<KeyTypeData<TKey> | null | undefined> {

Check warning on line 44 in src/primitives/createFragment.ts

View check run for this annotation

Codecov / codecov/patch

src/primitives/createFragment.ts#L44

Added line #L44 was not covered by tests
return createFragmentInternal(fragment, key);
}

Expand All @@ -55,21 +51,67 @@
options?: Accessor<{
parentOperation: Subscribable<GraphQLResponse> | null | undefined;
}>,
): DataProxy<KeyTypeData<TKey> | null | undefined> {
): DataStore<KeyTypeData<TKey> | null | undefined> {

Check warning on line 54 in src/primitives/createFragment.ts

View check run for this annotation

Codecov / codecov/patch

src/primitives/createFragment.ts#L54

Added line #L54 was not covered by tests
const environment = useRelayEnvironment();

const source = createMemo(() => {
const k = unwrap(key());
return k && observeFragment(environment(), fragment, k);
});
const initialResult: FragmentResult<TKey[" $data"]> = {
data: undefined,
error: undefined,
pending: false,
};

Check warning on line 61 in src/primitives/createFragment.ts

View check run for this annotation

Codecov / codecov/patch

src/primitives/createFragment.ts#L57-L61

Added lines #L57 - L61 were not covered by tests

type FragmentObserver = Parameters<
ReturnType<typeof observeFragment>["subscribe"]
>[0];
const resultUpdateObserver = {
next(res) {
batch(() => {
switch (res.state) {
case "ok":
setResult("error", undefined);
setResult("pending", false);
setResult(
"data",
reconcile(res.value as Record<string, unknown>, {
key: "__id",
merge: true,
}),
);
break;
case "error":
setResult("data", undefined);
setResult("error", res.error);
setResult("pending", false);
break;
}
});
},
} satisfies FragmentObserver;
const [subscription, setSubscription] = createSignal<Subscription>();

Check warning on line 90 in src/primitives/createFragment.ts

View check run for this annotation

Codecov / codecov/patch

src/primitives/createFragment.ts#L66-L90

Added lines #L66 - L90 were not covered by tests

const setResultQueue: unknown[][] = [];
let setResult: SetStoreFunction<FragmentResult<TKey[" $data"]>> = (
...args: unknown[]
) => {
setResultQueue.push(args);
};

Check warning on line 97 in src/primitives/createFragment.ts

View check run for this annotation

Codecov / codecov/patch

src/primitives/createFragment.ts#L92-L97

Added lines #L92 - L97 were not covered by tests
Comment on lines +92 to +97

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The introduction of setResultQueue and initializing setResult to a queuing function is an interesting pattern. This presumably handles updates that might occur (e.g., from the resource's source function or early parts of the fetcher) before createDataStore has fully initialized and returned the actual setStore function that setResult is later reassigned to.

Could you elaborate a bit on the necessity of this queuing mechanism? Was it primarily to address specific timing issues encountered with createResource's execution lifecycle in relation to the createDataStore (and its internal createStableStore) setup, especially when a store might be retrieved from the cache? Understanding the trade-offs or if alternative patterns were considered would be helpful for long-term maintainability.


const [resource] = createResource(
() => {
const s = source();
if (!s) return;
return { source: s, parentOperation: options?.().parentOperation };
batch(() => {
untrack(subscription)?.unsubscribe();
setSubscription(undefined);
setResult(initialResult);
});

Check warning on line 105 in src/primitives/createFragment.ts

View check run for this annotation

Codecov / codecov/patch

src/primitives/createFragment.ts#L101-L105

Added lines #L101 - L105 were not covered by tests

void environment();
const k = unwrap(key());
if (!k) return;
return { key: k, parentOperation: options?.().parentOperation };

Check warning on line 110 in src/primitives/createFragment.ts

View check run for this annotation

Codecov / codecov/patch

src/primitives/createFragment.ts#L107-L110

Added lines #L107 - L110 were not covered by tests
},
async ({ source, parentOperation }) => {
async ({ key, parentOperation }) => {
setResult("pending", true);

Check warning on line 113 in src/primitives/createFragment.ts

View check run for this annotation

Codecov / codecov/patch

src/primitives/createFragment.ts#L112-L113

Added lines #L112 - L113 were not covered by tests

if (parentOperation) {
await new Promise<void>((resolve, reject) => {
parentOperation.subscribe({
Expand All @@ -79,66 +121,45 @@
});
}

const source = observeFragment(environment(), fragment, key);

Check warning on line 124 in src/primitives/createFragment.ts

View check run for this annotation

Codecov / codecov/patch

src/primitives/createFragment.ts#L124

Added line #L124 was not covered by tests

return new Promise<true>((resolve, reject) => {
const subscription = source.subscribe({
next(value) {
if (value.state === "ok") {
resolve(true);
queueMicrotask(() => subscription.unsubscribe());
} else if (value.state === "error") {
reject(value.error);
queueMicrotask(() => subscription.unsubscribe());
}
},
});
setSubscription(
source.subscribe({
next(res) {
resultUpdateObserver.next(res);
if (res.state === "ok") resolve(true);
else if (res.state === "error") reject(res.error);
},
}),
);
}).finally(() => {
if (isServer) {
subscription()?.unsubscribe();
setSubscription(undefined);
}

Check warning on line 140 in src/primitives/createFragment.ts

View check run for this annotation

Codecov / codecov/patch

src/primitives/createFragment.ts#L127-L140

Added lines #L127 - L140 were not covered by tests
});
},
);

const initialResult: FragmentResult<TKey[" $data"]> = {
data: undefined,
error: undefined,
pending: false,
};
const [result, setResult] =
createStore<FragmentResult<TKey[" $data"]>>(initialResult);

createComputed(() => {
setResult(initialResult);
const currentSource = source();
if (!currentSource) return;

setResult("pending", true);

const subscription = currentSource.subscribe({
next(res) {
batch(() => {
switch (res.state) {
case "ok":
setResult("error", undefined);
setResult("pending", false);
setResult(
"data",
reconcile(res.value as Record<string, unknown>, {
key: "__id",
merge: true,
}),
);
break;
case "error":
setResult("data", undefined);
setResult("error", res.error);
setResult("pending", false);
break;
}
});
{
onHydrated(source) {
if (!source) return;
setSubscription(
observeFragment(environment(), fragment, source.key).subscribe(
resultUpdateObserver,
),
);

Check warning on line 150 in src/primitives/createFragment.ts

View check run for this annotation

Codecov / codecov/patch

src/primitives/createFragment.ts#L143-L150

Added lines #L143 - L150 were not covered by tests
},
});
},
);

Check warning on line 153 in src/primitives/createFragment.ts

View check run for this annotation

Codecov / codecov/patch

src/primitives/createFragment.ts#L152-L153

Added lines #L152 - L153 were not covered by tests

onCleanup(() => {
subscription.unsubscribe();
});
});
const store = createDataStore<FragmentResult<TKey[" $data"]>>(
initialResult,
resource,
);
for (const args of setResultQueue) {
store[1].apply(undefined, args as never);
}
setResult = store[1];

Check warning on line 162 in src/primitives/createFragment.ts

View check run for this annotation

Codecov / codecov/patch

src/primitives/createFragment.ts#L155-L162

Added lines #L155 - L162 were not covered by tests

return makeDataProxy(result, resource);
return store[0];

Check warning on line 164 in src/primitives/createFragment.ts

View check run for this annotation

Codecov / codecov/patch

src/primitives/createFragment.ts#L164

Added line #L164 was not covered by tests
}
40 changes: 15 additions & 25 deletions src/primitives/createLazyLoadQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,14 @@ import {
createComputed,
createMemo,
createResource,
createSignal,
onCleanup,
} from "solid-js";
import { createStore, reconcile } from "solid-js/store";
import { reconcile } from "solid-js/store";
import { useRelayEnvironment } from "../RelayEnvironment";
import { type QueryCacheEntry, getQueryCache } from "../queryCache";
import { type MaybeAccessor, access } from "../utils/access";
import { createMemoOperationDescriptor } from "../utils/createMemoOperationDescriptor";
import { type DataProxy, makeDataProxy } from "../utils/dataProxy";
import { type DataStore, createDataStore } from "../utils/dataStore";
import { getQueryRef } from "../utils/getQueryRef";

type QueryResult<T> =
Expand All @@ -55,7 +54,7 @@ export function createLazyLoadQuery<TQuery extends OperationType>(
fetchPolicy?: MaybeAccessor<FetchPolicy | undefined>;
networkCacheConfig?: MaybeAccessor<CacheConfig | undefined>;
},
): DataProxy<TQuery["response"]> {
): DataStore<TQuery["response"]> {
const environment = useRelayEnvironment();
const operation = createMemoOperationDescriptor(
gqlQuery,
Expand Down Expand Up @@ -85,10 +84,9 @@ export function createLazyLoadQueryInternal<
fetchObservable: Accessor<Observable<GraphQLResponse> | null | undefined>;
fetchKey?: Accessor<string | number | null | undefined>;
fetchPolicy?: Accessor<FetchPolicy | undefined>;
}): DataProxy<TQuery["response"]> {
}): DataStore<TQuery["response"]> {
const environment = useRelayEnvironment();
const queryCache = createMemo(() => getQueryCache(environment()));
const [serverData, setServerData] = createSignal<TQuery["response"]>();

const isLiveQuery = createMemo(
() => params.query().request.node.params.metadata.live !== undefined,
Expand Down Expand Up @@ -182,7 +180,7 @@ export function createLazyLoadQueryInternal<
},
);

let entry: QueryCacheEntry = false;
let entry: QueryCacheEntry = null;
if (shouldFetch) {
const subscription = subscriptionTarget?.subscribe({});
let retainCount = 0;
Expand Down Expand Up @@ -224,14 +222,10 @@ export function createLazyLoadQueryInternal<
error: undefined,
pending: true,
};
const [result, setResult] =
createStore<QueryResult<TQuery["response"]>>(initialResult);

const updateData = (data: TQuery["response"]) => {
if (typeof window !== "undefined") {
setResult("data", reconcile(data, { key: "__id", merge: true }));
} else setServerData(() => data);
};
const [result, setResult] = createDataStore<QueryResult<TQuery["response"]>>(
initialResult,
() => cacheEntry()?.resource,
);

createComputed(() => {
setResult(initialResult);
Expand All @@ -250,9 +244,12 @@ export function createLazyLoadQueryInternal<
if (state.state === "ok") {
setResult("error", undefined);
setResult("pending", false);
updateData(state.value);
setResult(
"data",
reconcile(state.value, { key: "__id", merge: true }),
);
} else if (state.state === "error") {
updateData(undefined);
setResult("data", undefined);
setResult("error", state.error);
setResult("pending", false);
}
Expand All @@ -264,12 +261,5 @@ export function createLazyLoadQueryInternal<
});
});

return makeDataProxy(
result,
() => {
const entry = cacheEntry();
if (entry) entry.resource();
},
typeof window === "undefined" ? serverData : undefined,
);
return result;
}
6 changes: 3 additions & 3 deletions src/primitives/createPaginationFragment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import invariant from "tiny-invariant";
import { useRelayEnvironment } from "../RelayEnvironment";
import type { KeyType, KeyTypeData } from "../types/keyType";
import { createFetchTracker } from "../utils/createFetchTracker";
import type { DataProxy } from "../utils/dataProxy";
import type { DataStore } from "../utils/dataStore";
import { getConnectionState } from "../utils/getConnectionState";
import { useIsMounted } from "../utils/useIsMounted";
import { useIsOperationNodeActive } from "../utils/useIsOperationNodeActive";
Expand All @@ -43,7 +43,7 @@ type CreatePaginationFragmentReturn<
TQuery extends OperationType,
TKey extends KeyType | null | undefined,
TFragmentData,
> = DataProxy<TFragmentData> & {
> = DataStore<TFragmentData> & {
loadNext: LoadMoreFn<TQuery>;
loadPrevious: LoadMoreFn<TQuery>;
hasNext: boolean;
Expand Down Expand Up @@ -169,7 +169,7 @@ function createLoadMore<TQuery extends OperationType, TKey extends KeyType>({
fragmentNode: ReaderFragment;
fragmentRef: Accessor<TKey | null | undefined>;
fragmentIdentifier: Accessor<string>;
fragmentData: DataProxy<KeyTypeData<TKey>>;
fragmentData: DataStore<KeyTypeData<TKey>>;
connectionPathInFragmentData: readonly (string | number)[];
paginationRequest: ConcreteRequest;
paginationMetadata: ReaderPaginationMetadata;
Expand Down
Loading