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

Fix error caused by updating url too frequently #726

Merged
merged 3 commits into from
Nov 9, 2023
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
82 changes: 82 additions & 0 deletions app/scripts/components/exploration/atoms/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Atom values and url

Jotai and its atoms are used to store the state for the exploration page.
Since this page's state needs to be shareable between users, some of the state needs to end up in the url, which is achieved using jotai-location.

Its usage is, in principle, simple: We create an atom that communicates with the url, so every time a value is set, the url is updated and vice versa.

### Problem 1 - URL update limits
The browser has a limit to how many updates can be made to the url in a given amount of time. When this limit is reached an error is thrown that crashes the app.

This was resolved by creating an atom that holds the same value as the url but without updating it. In this way we can make all the updates we want. We then debounce the url update so it is not frequent enough to reach the limit.

See the code in [./url.ts](./url.ts).

### Problem 2 - URL as source of truth
Using the url as a source of truth means that all data that goes to the url is converted to and from string. I.e. every time we want to read a value we parse the url string value.

Example for a simple value
```ts
const valueAtom = atom(
(get) => {
const value = get(urlAtom).searchParams?.get('value');
return value;
},
(get, set, updates) => {
set(urlAtom, updates);
}
);
```

This is not a problem for most values, but it is for objects because the resulting value will always be a new object, even if the source string is the same.

Example for an object
```ts
const valueAtom = atom(
(get) => {
const value = get(urlAtom).searchParams?.get('value');
return value ? JSON.parse(value) : null;
},
(get, set, updates) => {
set(urlAtom, JSON.stringify(updates));
}
);
```

The result is that these values cannot be used dependencies for hooks because they will always change.
For example, the selectedDate is converted to a ISO string when it goes to the url, and when we create a Date from the string it will be different.

The solution used here was to create an atom that ensures url value stability at the same time that interfaces with with the url atom.
The code for the atom can be found in [./atom-with-url-value-stability.ts](./atom-with-url-value-stability.ts).

This atom is still a little complex because it needs to do a number of things:
- To get the value:
- Get the value from the url
- Convert the value from string to the desired type (hydrate step)
- Reconcile the value from the url with the internal stored value. This is important for cases where the atom value is so complex that the information that goes to the url needs to be simplified, as with datasets (reconcile step)
- Check if the stored atom and the reconciled value are the same. (areEqual step)
- To store the value:
- Convert the value to string (dehydrate step)
- Update the url with the new value by writing to the url atom
- Update the internal stored value using the atom compare that only updates if the value is different (areEqual step)

An example code to instantiate such atom would be:
```ts
const atom = atomWithUrlValueStability({
initialValue: { a: 0 },
urlParam: 'someKey',
hydrate: (serialized) => {
return JSON.parse(serialized);
},
dehydrate: (value) => {
return JSON.stringify(value);
},
reconcile: (urlValue, storageValue) => {
return someReconcileFunction(urlValue, storageValue);
},
areEqual: (a, b) => {
return a.a === b.a;
},
});

```
8 changes: 8 additions & 0 deletions app/scripts/components/exploration/atoms/analysis.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { atom } from 'jotai';

// Analysis controller. Stores high level state about the analysis process.
export const analysisControllerAtom = atom({
isAnalyzing: false,
runIds: {} as Record<string, number | undefined>,
isObsolete: false
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { atom, SetStateAction } from 'jotai';
import { atomWithReducer } from 'jotai/utils';
import { setUrlParam, urlAtom } from './url';

export function atomWithCompare<Value>(
initialValue: Value,
areEqual: (prev: Value, next: Value) => boolean
) {
return atomWithReducer(initialValue, (prev: Value, next: Value) => {
if (areEqual(prev, next)) {
return prev;
}

return next;
});
}

function isEqual(prev, next) {
if (typeof next === 'object') {
const nextString = JSON.stringify(next);
const prevString = JSON.stringify(prev);
return prevString === nextString;
}

return prev === next;
}

/**
* Options for creating a stable atom with a value that is synced to a URL parameter.
* @template Value The type of the value being stored in the atom.
* @template ValueUrl The type of the value being that is dehydrated/hydrated to/from the URL.
*/
interface StableAtomOptions<Value, ValueUrl> {
/**
* The initial value of the atom.
*/
initialValue: Value;
/**
* The name of the URL parameter to sync the atom value to.
*/
urlParam: string;
/**
* A function to convert the serialized URL parameter value to the atom value.
* @param serialized The serialized URL parameter value.
* @returns The deserialized atom value.
*/
hydrate: (serialized: string | null | undefined) => ValueUrl;
/**
* A function to convert the atom value to a serialized URL parameter value.
* @param value The atom value.
* @returns The serialized URL parameter value.
*/
dehydrate: (value: Value) => string;
/**
* An optional function to reconcile the URL parameter value with the atom
* value. This is important for cases where the atom value is so complex that
* the information that goes to the url needs to be simplified.
* @param urlValue The value stored in the URL parameter after hydration.
* @param storageValue The value stored in the atom.
* @returns The reconciled value. If the function is not provided the urlValue
* is considered the reconciled value.
*/
reconcile?: (urlValue: ValueUrl, storageValue: Value) => Value;
/**
* An optional function to compare two atom values for equality.
* @param prev The previous atom value.
* @param next The next atom value.
* @returns Whether the two values are equal.
*/
areEqual?: (prev: Value, next: Value) => boolean;
}

export function atomWithUrlValueStability<T, TUrl = T>(
options: StableAtomOptions<T, TUrl>
) {
const {
initialValue,
urlParam,
hydrate,
dehydrate,
reconcile = (h) => h as unknown as T,
areEqual = isEqual
} = options;
// Store the value in an atom that only updates if the value is different.
const storage = atomWithCompare<T>(initialValue, areEqual);

const stableAtom = atom(
(get) => {
// Get value from the url according to the urlParam.
const serialized = get(urlAtom).searchParams?.get(urlParam);
// Hydrate the value from the url.
const hydrated = hydrate(serialized);
const storageValue = get(storage);

// Reconcile the hydrated value with the storage value.
const reconciled = reconcile(hydrated, storageValue);

// If the reconciled value is equal to the storage value, return the
// storage value to ensure equality.
return areEqual(storageValue, reconciled)
? (storageValue as T)
: reconciled;
},
(get, set, updates: SetStateAction<T>) => {
// Since updates can be a function, we need to get the correct new value.
const newData =
typeof updates === 'function'
? (updates as (prev: T) => T)(get(stableAtom))
: updates;

// Dehydrate the new value to a string for the url.
const dehydrated = dehydrate(newData);
// The url atom will take care of debouncing the url updates.
set(urlAtom, setUrlParam(urlParam, dehydrated));

// Store value as provided by the user.
set(storage, newData);
}
);

return stableAtom;
}
Loading
Loading