Skip to content

Commit

Permalink
Fix async packages
Browse files Browse the repository at this point in the history
  • Loading branch information
lemonmade committed May 16, 2024
1 parent ddc5a6b commit ee907cc
Show file tree
Hide file tree
Showing 10 changed files with 231 additions and 53 deletions.
9 changes: 6 additions & 3 deletions integrations/react-query/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,18 @@ The `ReactQueryContext` takes care of ensuring that all queries made by your app
That’s all the setup you need! Elsewhere in your application, you can now use React Query’s [`useSuspenseQuery` hook](https://tanstack.com/query/v4/docs/reference/useSuspenseQuery) to load data in your components. The example below shows how you might use Quilt’s GraphQL utilities to perform type-safe GraphQL queries using React Query:

```tsx
import {createGraphQLHttpFetch} from '@quilted/quilt';
import {createGraphQLFetch} from '@quilted/quilt/graphql';
import {useSuspenseQuery} from '@tanstack/react-query';

import startQuery from './Start.graphql';

const query = createGraphQLHttpFetch({uri: 'https://my-graphql-api.com'});
const query = createGraphQLFetch({uri: 'https://my-graphql-api.com'});

export function Start() {
const result = useSuspenseQuery('start-query', () => query(startQuery));
const result = useSuspenseQuery({
queryKey: ['start-query'],
queryFn: () => query(startQuery),
});

return <pre>{JSON.stringify(result, null, 2)}</pre>;
}
Expand Down
28 changes: 15 additions & 13 deletions packages/async/source/AsyncFetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,10 +136,11 @@ export class AsyncFetchCall<Data = unknown, Input = unknown> {
let resolve!: (value: Data) => void;
let reject!: (cause: unknown) => void;

this.promise = new AsyncFetchPromise(this, (res, rej) => {
this.promise = new AsyncFetchPromise((res, rej) => {
resolve = res;
reject = rej;
});
Object.assign(this.promise, {source: this});

this.resolve = resolve;
this.reject = reject;
Expand All @@ -160,11 +161,7 @@ export class AsyncFetchCall<Data = unknown, Input = unknown> {
};

call = (input?: Input, {signal}: {signal?: AbortSignal} = {}) => {
if (
this.runningSignal.peek() ||
this.promise.status === 'pending' ||
this.signal.aborted
) {
if (this.runningSignal.peek() || this.signal.aborted) {
throw new Error(`Can’t perform fetch()`);
}

Expand All @@ -178,6 +175,16 @@ export class AsyncFetchCall<Data = unknown, Input = unknown> {

return this.promise;
};

serialize(): AsyncFetchCallResult<Data, Input> | undefined {
if (this.promise.status === 'pending') return;

return {
value: this.value,
error: this.error,
input: this.input,
};
}
}

export class AsyncFetchPromise<
Expand All @@ -187,12 +194,9 @@ export class AsyncFetchPromise<
readonly status: 'pending' | 'fulfilled' | 'rejected' = 'pending';
readonly value?: Data;
readonly reason?: unknown;
readonly source: AsyncFetchCall<Data, Input>;
readonly source?: AsyncFetchCall<Data, Input>;

constructor(
source: AsyncFetchCall<Data, Input>,
executor: ConstructorParameters<typeof Promise<Data>>[0],
) {
constructor(executor: ConstructorParameters<typeof Promise<Data>>[0]) {
super((resolve, reject) => {
executor(
(value) => {
Expand All @@ -205,7 +209,5 @@ export class AsyncFetchPromise<
},
);
});

this.source = source;
}
}
108 changes: 108 additions & 0 deletions packages/async/source/AsyncFetchCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import {
AsyncFetch,
type AsyncFetchFunction,
type AsyncFetchCallResult,
} from './AsyncFetch.ts';

const EMPTY_ARRAY = Object.freeze([]);

export interface AsyncFetchCacheGetOptions<_Data = unknown, _Input = unknown> {
key?: unknown | readonly unknown[];
tags?: readonly string[];
}

export class AsyncFetchCache {
private readonly cache: Map<string, AsyncFetchCacheEntry<any, any>>;
private readonly initialCache: Map<string, AsyncFetchCallResult<any, any>>;

constructor(
initialCache: Iterable<
AsyncFetchCacheEntrySerialization<any>
> = EMPTY_ARRAY,
) {
this.cache = new Map();
this.initialCache = new Map();

for (const [key, value] of initialCache) {
this.initialCache.set(key, value);
}
}

get = <Data = unknown, Input = unknown>(
fetchFunction: AsyncFetchFunction<Data, Input>,
{
key: explicitKey,
tags = EMPTY_ARRAY,
}: AsyncFetchCacheGetOptions<Data, Input> = {},
) => {
const resolvedKey = explicitKey
? JSON.stringify(explicitKey)
: fetchFunction.toString();

let cacheEntry = this.cache.get(resolvedKey);

if (cacheEntry) return cacheEntry;

cacheEntry = new AsyncFetchCacheEntry<Data, Input>(fetchFunction, {
key: resolvedKey,
tags,
initial: this.initialCache.get(resolvedKey),
});

this.cache.set(resolvedKey, cacheEntry);

return cacheEntry;
};

restore(entries: Iterable<AsyncFetchCacheEntrySerialization<any>>) {
for (const [key, value] of entries) {
this.initialCache.set(key, value);
}
}

serialize(): readonly AsyncFetchCacheEntrySerialization<any>[] {
const serialized: AsyncFetchCacheEntrySerialization<any>[] = [];

for (const entry of this.cache.values()) {
const serializedEntry = entry.serialize();
if (serializedEntry) serialized.push(serializedEntry);
}

return serialized;
}
}

export class AsyncFetchCacheEntry<
Data = unknown,
Input = unknown,
> extends AsyncFetch<Data, Input> {
readonly key: string;
readonly tags: readonly string[];

constructor(
fetchFunction: AsyncFetchFunction<Data, Input>,
{
key,
initial,
tags = EMPTY_ARRAY,
}: {
key: string;
initial?: AsyncFetchCallResult<Data, Input>;
} & Omit<AsyncFetchCacheGetOptions<Data, Input>, 'key'>,
) {
super(fetchFunction, {initial});

this.key = key;
this.tags = tags;
}

serialize(): AsyncFetchCacheEntrySerialization<Data, Input> | undefined {
const serialized = this.finished?.serialize();
return serialized && [this.key, serialized];
}
}

export type AsyncFetchCacheEntrySerialization<
Data = unknown,
Input = unknown,
> = [key: string, result: AsyncFetchCallResult<Data, Input>];
8 changes: 4 additions & 4 deletions packages/async/source/AsyncModule.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {AsyncAction} from './AsyncAction.ts';
import {AsyncFetch} from './AsyncFetch.ts';

export interface AsyncModuleLoaderFunction<Module> {
(): Promise<Module>;
Expand Down Expand Up @@ -40,7 +40,7 @@ export class AsyncModule<Module> {
return this.loadAction.isRunning;
}

private readonly loadAction: AsyncAction<Module>;
private readonly loadAction: AsyncFetch<Module>;

constructor(load: AsyncModuleLoader<Module>) {
const id = (load as any).id;
Expand All @@ -50,7 +50,7 @@ export class AsyncModule<Module> {
Symbol.for('quilt')
]?.asyncModules?.get(id);

this.loadAction = new AsyncAction(
this.loadAction = new AsyncFetch(
() => (typeof load === 'function' ? load() : load.import()),
{initial: preloadedModule},
);
Expand All @@ -59,7 +59,7 @@ export class AsyncModule<Module> {
load = ({force = false} = {}) =>
!force && (this.isLoading || this.status !== 'pending')
? this.promise
: this.loadAction.run();
: this.loadAction.call();

import = this.load;
}
6 changes: 6 additions & 0 deletions packages/async/source/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,9 @@ export {
type AsyncModuleLoaderFunction,
type AsyncModuleLoaderObject,
} from './AsyncModule.ts';
export {
AsyncFetchCache,
AsyncFetchCacheEntry,
type AsyncFetchCacheGetOptions,
type AsyncFetchCacheEntrySerialization,
} from './AsyncFetchCache.ts';
50 changes: 42 additions & 8 deletions packages/preact-async/source/AsyncContext.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,60 @@
import type {RenderableProps} from 'preact';
import {useMemo, useLayoutEffect} from 'preact/hooks';
import {signal} from '@quilted/preact-signals';
import {useRef, useLayoutEffect} from 'preact/hooks';

import {AsyncHydratedContext} from './context.ts';
import type {AsyncFetchCache} from '@quilted/async';
import {signal, type Signal} from '@quilted/preact-signals';
import {useBrowserDetails} from '@quilted/preact-browser';

import {AsyncHydratedContext, AsyncFetchCacheContext} from './context.ts';

/**
* Only needed for the following features:
*
* - `<AsyncComponent server={false}>` (needed to correctly hydrate client-only components)
*/
export function AsyncContext({children}: RenderableProps<{}>) {
const hydrationSignal = useMemo(() => signal(false), []);
export function AsyncContext({
cache,
children,
}: RenderableProps<{
cache: AsyncFetchCache;
}>) {
const browser = useBrowserDetails();
const internals = useRef<{
hydrated: Signal<boolean>;
deserialized: boolean;
}>({deserialized: false} as any);
if (internals.current.hydrated == null) {
Object.assign(internals.current, {hydrated: signal(false)});
}

const {hydrated, deserialized} = internals.current;

if (typeof document === 'object') {
if (!deserialized) {
const serialization = browser.serializations.get('quilt:fetch:hydrated');
if (Array.isArray(serialization)) cache.restore(serialization);

internals.current.deserialized = true;
}

useLayoutEffect(() => {
hydrationSignal.value = true;
hydrated.value = true;
}, []);
} else {
browser.serializations.set('quilt:fetch:cache', () => cache.serialize());
}

return (
<AsyncHydratedContext.Provider value={hydrationSignal}>
const content = (
<AsyncHydratedContext.Provider value={hydrated}>
{children}
</AsyncHydratedContext.Provider>
);

return cache ? (
<AsyncFetchCacheContext.Provider value={cache}>
{content}
</AsyncFetchCacheContext.Provider>
) : (
content
);
}
7 changes: 5 additions & 2 deletions packages/preact-async/source/context.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import type {AsyncFetchCache} from '@quilted/async';
import type {ReadonlySignal} from '@quilted/preact-signals';
import {createOptionalContext} from '@quilted/preact-context';
import type {Signal} from '@quilted/preact-signals';

export const AsyncHydratedContext = createOptionalContext<Signal<boolean>>();
export const AsyncHydratedContext =
createOptionalContext<ReadonlySignal<boolean>>();
export const AsyncFetchCacheContext = createOptionalContext<AsyncFetchCache>();
22 changes: 0 additions & 22 deletions packages/preact-async/source/hooks/action.ts

This file was deleted.

44 changes: 44 additions & 0 deletions packages/preact-async/source/hooks/fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {useRef} from 'preact/hooks';

import {AsyncFetch} from '@quilted/async';
import type {
AsyncFetchCacheGetOptions,
AsyncFetchFunction,
} from '@quilted/async';
import {useComputed} from '@quilted/preact-signals';

import {AsyncFetchCacheContext} from '../context.ts';

export const useAsyncFetchCache = AsyncFetchCacheContext.use;

export async function useAsyncFetch<Data, Input>(
fetchFunction: AsyncFetchFunction<Data, Input>,
options?: AsyncFetchCacheGetOptions<Data, Input>,
) {
const functionRef = useRef(fetchFunction);
functionRef.current = fetchFunction;

const fetchCache = useAsyncFetchCache({optional: true});

const fetchSignal = useComputed(() => {
const resolvedFetchFunction: AsyncFetchFunction<Data, Input> = (...args) =>
functionRef.current(...args);

if (!fetchCache) {
return new AsyncFetch<Data, Input>(resolvedFetchFunction);
}

// TODO: react to key changes
return fetchCache.get(resolvedFetchFunction, options);
}, [fetchCache]);

const fetch = fetchSignal.value;

if (fetch.status === 'pending') {
console.log(fetch.status, fetch.isRunning);
if (fetch.isRunning) throw fetch.promise;
throw fetch.call();
}

return fetch;
}
2 changes: 1 addition & 1 deletion packages/preact-async/source/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export * from '@quilted/async';
export {AsyncComponent} from './AsyncComponent.tsx';
export {AsyncContext} from './AsyncContext.tsx';

export {useAsyncAction} from './hooks/action.ts';
export {useAsyncFetch, useAsyncFetchCache} from './hooks/fetch.ts';
export {
useAsyncModule,
useAsyncModuleAssets,
Expand Down

0 comments on commit ee907cc

Please sign in to comment.