diff --git a/.changeset/fast-plants-dress.md b/.changeset/fast-plants-dress.md new file mode 100644 index 0000000..92c6b23 --- /dev/null +++ b/.changeset/fast-plants-dress.md @@ -0,0 +1,5 @@ +--- +"solid-relay": minor +--- + +fix: correctly and fully SSR on deferred fragments diff --git a/src/RelayEnvironment.ts b/src/RelayEnvironment.ts index ecf8ee3..245594b 100644 --- a/src/RelayEnvironment.ts +++ b/src/RelayEnvironment.ts @@ -2,6 +2,7 @@ import type { IEnvironment } from "relay-runtime"; import { type Accessor, type JSXElement, + type Resource, createComponent, createContext, createMemo, @@ -16,6 +17,7 @@ interface Props { const RelayContext = createContext<{ environment: Accessor; + dataStores: WeakMap, unknown>; }>(); export function RelayEnvironmentProvider(props: Props): JSXElement { @@ -23,7 +25,7 @@ export function RelayEnvironmentProvider(props: Props): JSXElement { return createComponent(RelayContext.Provider, { get value() { - return { environment }; + return { environment, dataStores: new WeakMap() }; }, get children() { return props.children; @@ -46,3 +48,10 @@ export function useRelayEnvironment(): () => IEnvironment { return context.environment; } + +export function useDataStores(): + | WeakMap, unknown> + | undefined { + const context = useContext(RelayContext); + return context?.dataStores; +} diff --git a/src/primitives/createFragment.ts b/src/primitives/createFragment.ts index ecc0953..672f9b3 100644 --- a/src/primitives/createFragment.ts +++ b/src/primitives/createFragment.ts @@ -2,20 +2,16 @@ import type { 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 = | { @@ -37,15 +33,15 @@ type FragmentResult = export function createFragment( fragment: GraphQLTaggedNode, key: Accessor, -): DataProxy>; +): DataStore>; export function createFragment( fragment: GraphQLTaggedNode, key: Accessor, -): DataProxy | null | undefined>; +): DataStore | null | undefined>; export function createFragment( fragment: GraphQLTaggedNode, key: Accessor, -): DataProxy | null | undefined> { +): DataStore | null | undefined> { return createFragmentInternal(fragment, key); } @@ -55,21 +51,67 @@ export function createFragmentInternal( options?: Accessor<{ parentOperation: Subscribable | null | undefined; }>, -): DataProxy | null | undefined> { +): DataStore | null | undefined> { const environment = useRelayEnvironment(); - const source = createMemo(() => { - const k = unwrap(key()); - return k && observeFragment(environment(), fragment, k); - }); + const initialResult: FragmentResult = { + data: undefined, + error: undefined, + pending: false, + }; + + type FragmentObserver = Parameters< + ReturnType["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, { + key: "__id", + merge: true, + }), + ); + break; + case "error": + setResult("data", undefined); + setResult("error", res.error); + setResult("pending", false); + break; + } + }); + }, + } satisfies FragmentObserver; + const [subscription, setSubscription] = createSignal(); + + const setResultQueue: unknown[][] = []; + let setResult: SetStoreFunction> = ( + ...args: unknown[] + ) => { + setResultQueue.push(args); + }; const [resource] = createResource( () => { - const s = source(); - if (!s) return; - return { source: s, parentOperation: options?.().parentOperation }; + batch(() => { + untrack(subscription)?.unsubscribe(); + setSubscription(undefined); + setResult(initialResult); + }); + + void environment(); + const k = unwrap(key()); + if (!k) return; + return { key: k, parentOperation: options?.().parentOperation }; }, - async ({ source, parentOperation }) => { + async ({ key, parentOperation }) => { + setResult("pending", true); + if (parentOperation) { await new Promise((resolve, reject) => { parentOperation.subscribe({ @@ -79,66 +121,45 @@ export function createFragmentInternal( }); } + const source = observeFragment(environment(), fragment, key); + return new Promise((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); + } }); }, - ); - - const initialResult: FragmentResult = { - data: undefined, - error: undefined, - pending: false, - }; - const [result, setResult] = - createStore>(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, { - 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, + ), + ); }, - }); + }, + ); - onCleanup(() => { - subscription.unsubscribe(); - }); - }); + const store = createDataStore>( + initialResult, + resource, + ); + for (const args of setResultQueue) { + store[1].apply(undefined, args as never); + } + setResult = store[1]; - return makeDataProxy(result, resource); + return store[0]; } diff --git a/src/primitives/createLazyLoadQuery.ts b/src/primitives/createLazyLoadQuery.ts index 65027e2..c05dd17 100644 --- a/src/primitives/createLazyLoadQuery.ts +++ b/src/primitives/createLazyLoadQuery.ts @@ -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 = @@ -55,7 +54,7 @@ export function createLazyLoadQuery( fetchPolicy?: MaybeAccessor; networkCacheConfig?: MaybeAccessor; }, -): DataProxy { +): DataStore { const environment = useRelayEnvironment(); const operation = createMemoOperationDescriptor( gqlQuery, @@ -85,10 +84,9 @@ export function createLazyLoadQueryInternal< fetchObservable: Accessor | null | undefined>; fetchKey?: Accessor; fetchPolicy?: Accessor; -}): DataProxy { +}): DataStore { const environment = useRelayEnvironment(); const queryCache = createMemo(() => getQueryCache(environment())); - const [serverData, setServerData] = createSignal(); const isLiveQuery = createMemo( () => params.query().request.node.params.metadata.live !== undefined, @@ -182,7 +180,7 @@ export function createLazyLoadQueryInternal< }, ); - let entry: QueryCacheEntry = false; + let entry: QueryCacheEntry = null; if (shouldFetch) { const subscription = subscriptionTarget?.subscribe({}); let retainCount = 0; @@ -224,14 +222,10 @@ export function createLazyLoadQueryInternal< error: undefined, pending: true, }; - const [result, setResult] = - createStore>(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>( + initialResult, + () => cacheEntry()?.resource, + ); createComputed(() => { setResult(initialResult); @@ -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); } @@ -264,12 +261,5 @@ export function createLazyLoadQueryInternal< }); }); - return makeDataProxy( - result, - () => { - const entry = cacheEntry(); - if (entry) entry.resource(); - }, - typeof window === "undefined" ? serverData : undefined, - ); + return result; } diff --git a/src/primitives/createPaginationFragment.ts b/src/primitives/createPaginationFragment.ts index 98e9de0..cc5090d 100644 --- a/src/primitives/createPaginationFragment.ts +++ b/src/primitives/createPaginationFragment.ts @@ -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"; @@ -43,7 +43,7 @@ type CreatePaginationFragmentReturn< TQuery extends OperationType, TKey extends KeyType | null | undefined, TFragmentData, -> = DataProxy & { +> = DataStore & { loadNext: LoadMoreFn; loadPrevious: LoadMoreFn; hasNext: boolean; @@ -169,7 +169,7 @@ function createLoadMore({ fragmentNode: ReaderFragment; fragmentRef: Accessor; fragmentIdentifier: Accessor; - fragmentData: DataProxy>; + fragmentData: DataStore>; connectionPathInFragmentData: readonly (string | number)[]; paginationRequest: ConcreteRequest; paginationMetadata: ReaderPaginationMetadata; diff --git a/src/primitives/createPreloadedQuery.ts b/src/primitives/createPreloadedQuery.ts index a9c5b4c..252c250 100644 --- a/src/primitives/createPreloadedQuery.ts +++ b/src/primitives/createPreloadedQuery.ts @@ -10,7 +10,7 @@ import { useRelayEnvironment } from "../RelayEnvironment"; import type { PreloadedQuery } from "../loadQuery"; import { type MaybeAccessor, access } from "../utils/access"; import { createMemoOperationDescriptor } from "../utils/createMemoOperationDescriptor"; -import type { DataProxy } from "../utils/dataProxy"; +import type { DataStore } from "../utils/dataStore"; import { createLazyLoadQueryInternal } from "./createLazyLoadQuery"; type MaybePromise = T | Promise; @@ -18,7 +18,7 @@ type MaybePromise = T | Promise; export function createPreloadedQuery( query: GraphQLTaggedNode, preloadedQuery: MaybeAccessor>>, -): DataProxy { +): DataStore { const environment = useRelayEnvironment(); const [maybePreloaded] = createResource( () => access(preloadedQuery), diff --git a/src/primitives/createRefetchableFragment.ts b/src/primitives/createRefetchableFragment.ts index 62c65f9..ffa60ae 100644 --- a/src/primitives/createRefetchableFragment.ts +++ b/src/primitives/createRefetchableFragment.ts @@ -30,9 +30,10 @@ import { untrack, } from "solid-js"; import { 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 } from "../utils/dataProxy"; +import type { DataStore } from "../utils/dataStore"; import { getQueryRef } from "../utils/getQueryRef"; import { useIsMounted } from "../utils/useIsMounted"; import { createFragmentInternal } from "./createFragment"; @@ -86,7 +87,7 @@ export function createRefetchableFragment< >( fragment: GraphQLTaggedNode, key: Accessor, -): [DataProxy>, RefetchFnDynamic]; +): [DataStore>, RefetchFnDynamic]; export function createRefetchableFragment< TQuery extends OperationType, TKey extends KeyType, @@ -94,7 +95,7 @@ export function createRefetchableFragment< fragment: GraphQLTaggedNode, key: Accessor, ): [ - DataProxy | null | undefined>, + DataStore | null | undefined>, RefetchFnDynamic, ]; export function createRefetchableFragment< @@ -104,7 +105,7 @@ export function createRefetchableFragment< fragment: GraphQLTaggedNode, key: Accessor, ): [ - DataProxy | null | undefined>, + DataStore | null | undefined>, RefetchFnDynamic, ] { const { fragmentData, refetch } = createRefetchableFragmentInternal( @@ -122,7 +123,7 @@ export function createRefetchableFragmentInternal< key: Accessor, componentDisplayName = "createRefetchableFragment()", ): { - fragmentData: DataProxy | null | undefined>; + fragmentData: DataStore | null | undefined>; fragmentRef: Accessor; refetch: RefetchFnDynamic; } { @@ -267,9 +268,13 @@ export function createRefetchableFragmentInternal< } }); - const fragmentData = createFragmentInternal(fragment, fragmentRef, () => ({ - parentOperation: refetchObservable(), - })); + const fragmentData = createFragmentInternal( + fragment, + isServer ? parentFragmentRef : fragmentRef, + () => ({ + parentOperation: refetchObservable(), + }), + ); return { fragmentData, diff --git a/src/queryCache.ts b/src/queryCache.ts index d7a56c0..d0a3a5b 100644 --- a/src/queryCache.ts +++ b/src/queryCache.ts @@ -1,11 +1,10 @@ import type { Disposable, IEnvironment } from "relay-runtime"; +import type { Resource } from "solid-js"; -export type QueryCacheEntry = - | { - resource: () => void; - retain: (environment: IEnvironment) => Disposable; - } - | false; +export type QueryCacheEntry = { + resource: Resource; + retain: (environment: IEnvironment) => Disposable; +} | null; const caches = new WeakMap>(); diff --git a/src/utils/dataProxy.ts b/src/utils/dataProxy.ts deleted file mode 100644 index 06b537f..0000000 --- a/src/utils/dataProxy.ts +++ /dev/null @@ -1,52 +0,0 @@ -export type DataProxy = - | { - (): T; - readonly latest: T; - readonly error: undefined; - readonly pending: false; - } - | { - (): undefined; - readonly latest: undefined; - readonly error: unknown; - readonly pending: false; - } - | { - (): undefined; - readonly latest: undefined; - readonly error: undefined; - readonly pending: true; - }; - -export const makeDataProxy = < - T extends { - readonly data: unknown; - readonly error: unknown; - readonly pending: boolean; - }, ->( - store: T, - resource: () => void, - data?: () => T["data"], -): DataProxy => { - const readData = () => { - void resource(); - const error = Reflect.get(store, "error"); - if (error) throw error; - return data ? data() : Reflect.get(store, "data"); - }; - - Object.defineProperties(readData, { - latest: { - get: () => Reflect.get(store, "data"), - }, - error: { - get: () => Reflect.get(store, "error"), - }, - pending: { - get: () => Reflect.get(store, "pending"), - }, - }); - - return readData as unknown as DataProxy; -}; diff --git a/src/utils/dataStore.ts b/src/utils/dataStore.ts new file mode 100644 index 0000000..d6d7a51 --- /dev/null +++ b/src/utils/dataStore.ts @@ -0,0 +1,89 @@ +import { type Resource, untrack } from "solid-js"; +import { type SetStoreFunction, createStore } from "solid-js/store"; +import { useDataStores } from "../RelayEnvironment"; +import type { MaybeAccessor } from "./access"; + +export type DataStore = + | { + (): T; + readonly latest: T; + readonly error: undefined; + readonly pending: false; + } + | { + (): undefined; + readonly latest: undefined; + readonly error: unknown; + readonly pending: false; + } + | { + (): undefined; + readonly latest: undefined; + readonly error: undefined; + readonly pending: true; + }; + +export const createDataStore = < + T extends { + readonly data: unknown; + readonly error: unknown; + readonly pending: boolean; + }, +>( + init: T, + maybeResource: MaybeAccessor | undefined>, +): [DataStore, SetStoreFunction] => { + const [store, setStore] = createStableStore( + init, + untrack(() => extractResource(maybeResource)), + ); + + const readData = () => { + const resource = extractResource(maybeResource); + void resource?.(); + const error = Reflect.get(store, "error"); + if (error) throw error; + return Reflect.get(store, "data"); + }; + + Object.defineProperties(readData, { + latest: { + get: () => Reflect.get(store, "data"), + }, + error: { + get: () => Reflect.get(store, "error"), + }, + pending: { + get: () => Reflect.get(store, "pending"), + }, + }); + + return [readData as unknown as DataStore, setStore]; +}; + +function createStableStore< + T extends { + readonly data: unknown; + readonly error: unknown; + readonly pending: boolean; + }, +>(init: T, resource: Resource | undefined) { + const stores = useDataStores(); + if (resource) { + const existing = stores?.get(resource); + if (existing) return existing as [T, SetStoreFunction]; + } + const store = createStore(init); + if (resource) stores?.set(resource, store); + return store; +} + +function extractResource( + maybeResource: MaybeAccessor | undefined>, +) { + return maybeResource + ? "loading" in maybeResource + ? maybeResource + : maybeResource() + : undefined; +}