Skip to content

fix: createAsync - catch errors of prev to avoid bubbling error up #531

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

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
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
47 changes: 37 additions & 10 deletions src/data/createAsync.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
/**
* This is mock of the eventual Solid 2.0 primitive. It is not fully featured.
*/
import { type Accessor, createResource, sharedConfig, type Setter, untrack } from "solid-js";
import {
type Accessor,
createResource,
sharedConfig,
type Setter,
untrack,
catchError
} from "solid-js";
import { createStore, reconcile, type ReconcileOptions, unwrap } from "solid-js/store";
import { isServer } from "solid-js/web";

Expand All @@ -13,7 +20,7 @@ import { isServer } from "solid-js/web";
export type AccessorWithLatest<T> = {
(): T;
latest: T;
}
};

export function createAsync<T>(
fn: (prev: T) => Promise<T>,
Expand All @@ -40,19 +47,28 @@ export function createAsync<T>(
}
): AccessorWithLatest<T | undefined> {
let resource: () => T;
let prev = () => !resource || (resource as any).state === "unresolved" ? undefined : (resource as any).latest;
let prev = () =>
!resource || (resource as any).state === "unresolved" ? undefined : (resource as any).latest;

[resource] = createResource(
() => subFetch(fn, untrack(prev)),
() =>
subFetch(
fn,
catchError(
() => untrack(prev),
() => undefined
)
),
v => v,
options as any
);

const resultAccessor: AccessorWithLatest<T> = (() => resource()) as any;
Object.defineProperty(resultAccessor, 'latest', {
Object.defineProperty(resultAccessor, "latest", {
get() {
return (resource as any).latest;
}
})
});

return resultAccessor;
}
Expand Down Expand Up @@ -85,9 +101,20 @@ export function createAsyncStore<T>(
} = {}
): AccessorWithLatest<T | undefined> {
let resource: () => T;
let prev = () => !resource || (resource as any).state === "unresolved" ? undefined : unwrap((resource as any).latest);

let prev = () =>
!resource || (resource as any).state === "unresolved"
? undefined
: unwrap((resource as any).latest);
[resource] = createResource(
() => subFetch(fn, untrack(prev)),
() =>
subFetch(
fn,
catchError(
() => untrack(prev),
() => undefined
)
),
v => v,
{
...options,
Expand All @@ -96,11 +123,11 @@ export function createAsyncStore<T>(
);

const resultAccessor: AccessorWithLatest<T> = (() => resource()) as any;
Object.defineProperty(resultAccessor, 'latest', {
Object.defineProperty(resultAccessor, "latest", {
get() {
return (resource as any).latest;
}
})
});

return resultAccessor;
}
Expand Down
156 changes: 156 additions & 0 deletions test/data.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import {
ErrorBoundary,
ParentProps,
Suspense,
catchError,
createRoot,
createSignal
} from "solid-js";
import { render } from "solid-js/web";
import { createAsync, createAsyncStore } from "../src/data";
import { awaitPromise, waitFor } from "./helpers";

function Parent(props: ParentProps) {
return <ErrorBoundary fallback={<div id="parentError" />}>{props.children}</ErrorBoundary>;
}

async function getText(arg?: string) {
return arg || "fallback";
}
async function getError(arg?: any): Promise<any> {
throw Error("error");
}

describe("createAsync should", () => {
test("return 'fallback'", () => {
createRoot(() => {
const data = createAsync(() => getText());
setTimeout(() => expect(data()).toBe("fallback"), 1);
});
});
test("return 'text'", () => {
createRoot(() => {
const data = createAsync(() => getText("text"));
setTimeout(() => expect(data()).toBe("text"), 1);
});
});
test("initial error to be caught ", () => {
createRoot(() => {
const data = createAsync(() => getError());
setTimeout(() => catchError(data, err => expect(err).toBeInstanceOf(Error)), 1);
});
});
test("catch error after arg change - initial valid", () =>
createRoot(async dispose => {
async function throwWhenError(arg: string): Promise<string> {
if (arg === "error") throw new Error("error");
return arg;
}

const [arg, setArg] = createSignal("");
function Child() {
const data = createAsync(() => throwWhenError(arg()));

return (
<div id="child">
<ErrorBoundary
fallback={(_, reset) => (
<div id="childError">
<button
id="reset"
onClick={() => {
setArg("true");
reset();
}}
/>
</div>
)}
>
<Suspense>
<p id="data">{data()}</p>
<p id="latest">{data.latest}</p>
</Suspense>
</ErrorBoundary>
</div>
);
}
await render(
() => (
<Parent>
<Child />
</Parent>
),
document.body
);
const childErrorElement = () => document.getElementById("childError");
const parentErrorElement = document.getElementById("parentError");
expect(childErrorElement()).toBeNull();
expect(parentErrorElement).toBeNull();
setArg("error");
await awaitPromise();

// after changing the arg the error should still be caught by the Child's ErrorBoundary
expect(childErrorElement()).not.toBeNull();
expect(parentErrorElement).toBeNull();

//reset ErrorBoundary
document.getElementById("reset")?.click();

expect(childErrorElement()).toBeNull();
await awaitPromise();
const dataEl = () => document.getElementById("data");

expect(dataEl()).not.toBeNull();
expect(document.getElementById("data")?.innerHTML).toBe("true");
expect(document.getElementById("latest")?.innerHTML).toBe("true");

document.body.innerHTML = "";
dispose();
}));
test("catch consecutive error after initial error change to be caught after arg change", () =>
createRoot(async cleanup => {
const [arg, setArg] = createSignal("error");
function Child() {
const data = createAsync(() => getError(arg()));

return (
<div id="child">
<ErrorBoundary
fallback={(_, reset) => (
<div id="childError">
<button id="reset" onClick={() => reset()} />
</div>
)}
>
<Suspense>{data()}</Suspense>
</ErrorBoundary>
</div>
);
}
await render(
() => (
<Parent>
<Child />
</Parent>
),
document.body
);

// Child's ErrorBoundary should catch the error
expect(document.getElementById("childError")).not.toBeNull();
expect(document.getElementById("parentError")).toBeNull();
setArg("error_2");
await awaitPromise();
// after changing the arg the error should still be caught by the Child's ErrorBoundary
expect(document.getElementById("childError")).not.toBeNull();
expect(document.getElementById("parentError")).toBeNull();

document.getElementById("reset")?.click();
await awaitPromise();
expect(document.getElementById("childError")).not.toBeNull();
expect(document.getElementById("parentError")).toBeNull();

document.body.innerHTML = "";
cleanup();
}));
});
4 changes: 4 additions & 0 deletions test/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,7 @@ export function createAsyncRoot(fn: (resolve: () => void, disposer: () => void)
createRoot(disposer => fn(resolve, disposer));
});
}

export async function awaitPromise() {
return new Promise(resolve => setTimeout(resolve, 100));
}
4 changes: 4 additions & 0 deletions test/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "./../tsconfig.json",
"include": ["."]
}
6 changes: 2 additions & 4 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@
"outDir": "./dist",
"module": "esnext"
},
"include": [
"./src"
],
"include": ["./src"],
"exclude": ["node_modules/"]
}
}