Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fetch: rewrite with composability in mind #131

Merged
merged 18 commits into from
Jul 30, 2022
Merged
5 changes: 5 additions & 0 deletions .changeset/odd-pens-buy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@solid-primitives/fetch": major
---

Rewrite to allow for an extendible primitive; the previously supported AbortControllers are now handled by the withAbort modifier. Additional modifiers mostly close the feature gap between @solid-primitives/fetch and react-query.
4 changes: 4 additions & 0 deletions packages/fetch/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,7 @@ Update solid.
1.2.0

Allow a RequestInfo Accessor to return undefined in order to not yet make a request.

2.0.0

Rewrite to allow for an extendible primitive; the previously supported AbortControllers are now handled by the withAbort modifier. Additional modifiers mostly close the feature gap between @solid-primitives/fetch and react-query.
39 changes: 34 additions & 5 deletions packages/fetch/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
[![size](https://img.shields.io/npm/v/@solid-primitives/fetch?style=for-the-badge)](https://www.npmjs.com/package/@solid-primitives/fetch)
[![stage](https://img.shields.io/endpoint?style=for-the-badge&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-3.json)](https://github.com/solidjs-community/solid-primitives#contribution-process)

Creates a primitive to support abortable HTTP requests. If any reactive request options changes, the request is aborted automatically.
Creates a composable primitive to support requests.

## Installation

Expand All @@ -34,23 +34,52 @@ If you fail to install it, but still run it on the server, you should see a nice
## How to use it

```ts
const [resource, { mutate, refetch, abort }] = createFetch<T>(
const [resource, { mutate, refetch }] = createFetch<T>(
requestInfo: Accessor<RequestInfo | undefined> | RequestInfo,
requestInit?: Accessor<RequestInit | undefined> | RequestInit | undefined,
options?: { initialValue: T, name?: string }
options?: { disable?: boolean } & ResourceOptions<T>,
modifiers?: RequestModifier[]
);

resource(): T
resource.aborted: boolean
resource.error: Error | any | undefined
resource.loading: boolean
resource.status: number | null
resource.response: Response
```

Remember, just like with [`createResource`](https://www.solidjs.com/docs/latest/api#createresource), you will need an [`<ErrorBoundary>`](https://www.solidjs.com/docs/latest/api#%3Cerrorboundary%3E) to catch the errors, even if they are accessible inside the resource. Otherwise, uncaught errors might disrupt your application.
Remember, just like with [`createResource`](https://www.solidjs.com/docs/latest/api#createresource), you will need an [`<ErrorBoundary>`](https://www.solidjs.com/docs/latest/api#%3Cerrorboundary%3E) to catch the errors, even if they are accessible inside the resource. Otherwise, uncaught errors might disrupt your application - except if you use the `withCatchAll()` modifier.

If you want to initialize a fetch request without directly starting it, you can use an Accessor that returns undefined before being set to the actual request info or url. Even if you add a RequestInit, the request will not be started without a defined RequestInfo.

### Modifiers

The fetch primitive alone just wraps a simple fetch request in a solid resource for convenience, but its ability to compose modifiers are what makes this primitive really powerful. The following modifiers are supported:

```ts
// makes the request abortable; will automatically abort previous requests or those whose owner got disposed
withAbort()

// will abort the request if abortable and throw an error after a certain timeout
withTimeout(after: number)

// catches all request errors so you no longer require an ErrorBoundary
withCatchAll()

// retries failed requests after a certain time, will by default wait the number of the retry seconds,
// starting with 1, up to 30s
withRetry(retries: number, wait: number | (retry: number) => number)

// refetches the request after certain events
withRefetchEvent({ on: keyof HTMLWindowEventMap[], filter: (...args, data, event) => boolean })

// caches requests
withCache({ cache?: Record<string, CacheEntry>, expires?: number | ((entry: CacheEntry) => boolean); })

// makes cache persistent in storage, defaults = [localStorage, 'fetch-cache']
withCacheStorage(storage?: Storage, key?: string)
```

## Demo

TODO
Expand Down
1 change: 1 addition & 0 deletions packages/fetch/dev/assets/abort.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
You shouldn't be able to read that!
1 change: 1 addition & 0 deletions packages/fetch/dev/assets/test.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "text": "this is json loaded from a file" }
1 change: 1 addition & 0 deletions packages/fetch/dev/assets/test.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Plain text from a fetched file
35 changes: 35 additions & 0 deletions packages/fetch/dev/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<title>Solid App</title>
<style>
html {
font-family: "Gill Sans", "Gill Sans MT", Calibri, "Trebuchet MS", sans-serif;
}

body {
padding: 0;
margin: 0;
}

a,
button {
cursor: pointer;
}

* {
margin: 0;
}
</style>
</head>

<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>

<script src="./index.tsx" type="module"></script>
</body>
</html>
34 changes: 34 additions & 0 deletions packages/fetch/dev/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Component } from "solid-js";
import { render } from "solid-js/web";
import "uno.css";
import { withAbort } from "../src/modifiers";

import { createFetch } from "../src/fetch";

const App: Component = () => {
const [text] = createFetch("assets/test.txt");
const [json] = createFetch<{ text: string }>("assets/test.json");
const [aborted, { abort }] = createFetch(
"assets/abort.txt",
{ initialValue: "this is a fallback after abort" },
[withAbort()]
);

abort();

return (
<div class="p-24 box-border w-full min-h-screen flex flex-col justify-center items-center space-y-4 bg-gray-800 text-white">
<div class="wrapper-v">
<h4>Loading plain text</h4>
<p>{text.loading ? "Loading..." : text()}</p>
<h4>Loading JSON data</h4>
<p>{json.loading ? "Loading..." : json().text}</p>
<h4>Aborting a request</h4>
<p>{aborted.loading ? "Loading..." : aborted()}</p>
<h4></h4>
</div>
</div>
);
};

render(() => <App />, document.getElementById("root")!);
2 changes: 2 additions & 0 deletions packages/fetch/dev/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import { viteConfig } from "../../../vite.config";
export default viteConfig;
14 changes: 9 additions & 5 deletions packages/fetch/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,12 @@
"require": "./dist/index.cjs"
},
"scripts": {
"start": "vite serve dev --host",
"dev": "yarn start",
"prebuild": "npm run clean",
"clean": "rimraf dist/",
"build": "tsup",
"test": "uvu -r solid-register"
"test": "vitest"
},
"keywords": [
"fetch",
Expand All @@ -48,14 +50,16 @@
"primitives"
],
"devDependencies": {
"@types/node": "^17.0.45",
"jsdom": "^19.0.0",
"@types/node": "^18.0.6",
"jsdom": "^20.0.0",
"prettier": "^2.7.1",
"solid-register": "^0.2.5",
"rimraf": "^3.0.2",
"tslib": "^2.4.0",
"tsup": "^6.1.3",
"typescript": "^4.7.4",
"uvu": "^0.5.6"
"vite": "^2.9.9",
"vite-plugin-solid": "2.2.6",
"vitest": "^0.19.1"
},
"peerDependencies": {
"node-fetch": ">=2.0.0",
Expand Down
111 changes: 111 additions & 0 deletions packages/fetch/src/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { DEV } from "solid-js";
import { RequestContext } from "./fetch";
import { RequestModifier, wrapFetcher } from "./modifiers";

export type CacheEntry<T = any> = {
ts: number;
requestData: [info: RequestInfo, init?: RequestInit];
data: T;
};

export type RequestCache<T = any> = Record<string, CacheEntry<T>>;

export type CacheOptions<T = any> = {
expires: number | ((entry: CacheEntry<T>) => boolean);
cache?: RequestCache<T>;
};

export const defaultCacheOptions: CacheOptions = {
expires: 5000,
cache: {}
};

export const serializeRequest = <FetcherArgs extends any[]>(requestData: FetcherArgs): string =>
JSON.stringify({
...(typeof requestData[0] === "string" ? { url: requestData[0] } : requestData[0]),
...requestData[1]
});

/**
* Modifies createFetch request to support caching
* ```ts
* withCache({
* expires: number | ((entry: CacheEntry<T>) => boolean);
* cache?: RequestCache<T>; // global cache by default
* })
* ```
* `CacheEntry` is structured as follows:
* ```ts
* type CacheEntry<T = any> = {
* ts: number;
* requestData: [info: RequestInfo, init?: RequestInit];
* data: T;
* };
* ```
* The RequestCache is a simple object.
*/
export const withCache: RequestModifier =
<Result extends unknown, FetcherArgs extends any[]>(
options: CacheOptions = defaultCacheOptions
) =>
(requestContext: RequestContext<Result, FetcherArgs>) => {
requestContext.cache = options.cache || requestContext.cache;
const isExpired = (entry: CacheEntry) =>
typeof options.expires === "number"
? entry.ts + options.expires < new Date().getTime()
: options.expires(entry);
wrapFetcher<Result, FetcherArgs>(
requestContext,
<T>(originalFetcher: any) =>
(requestData, info) => {
const serializedRequest = serializeRequest(requestData);
const cached: CacheEntry | undefined = requestContext.cache[serializedRequest];
const shouldRead = requestContext.readCache?.(cached) !== false;
if (cached && !isExpired(cached) && shouldRead) {
return Promise.resolve<T>(cached.data);
}
return originalFetcher(requestData, info).then((data: T) => {
const cacheEntry = {
ts: new Date().getTime(),
requestData: requestData,
data
};
requestContext.cache[serializedRequest] = cacheEntry;
requestContext.writeCache?.(serializedRequest, cacheEntry);
return data;
});
}
);
requestContext.wrapResource();
Object.assign(requestContext.resource![1], {
invalidate: (requestData: FetcherArgs) => {
try {
delete requestContext.cache[serializeRequest(requestData)];
} catch (e) {
DEV &&
atk marked this conversation as resolved.
Show resolved Hide resolved
console.warn("attempt to invalidate cache for", requestData, "failed with error", e);
}
}
});
};

export const withCacheStorage: RequestModifier =
(storage: Storage = localStorage, key = "fetch-cache") =>
requestContext => {
try {
const loadedCache = JSON.parse(storage.getItem(key) || "{}");
Object.assign(requestContext.cache, loadedCache);
} catch (e) {
DEV && console.warn("attempt to parse stored request cache failed with error", e);
}
const originalWriteCache = requestContext.writeCache;
requestContext.writeCache = (...args: any[]) => {
originalWriteCache?.(...args);
try {
storage.setItem(key, JSON.stringify(requestContext.cache));
} catch (e) {
DEV && console.warn("attempt to store request cache failed with error", e);
}
};
requestContext.wrapResource();
};
Loading