Skip to content
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
5 changes: 4 additions & 1 deletion __tests__/utils/useAtomsDevtools.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,10 @@ describe('useAtomsDevtools', () => {
</StrictMode>,
);

expect(extension.init).toHaveBeenLastCalledWith(undefined);
expect(extension.init).toHaveBeenLastCalledWith({
dependents: {},
values: {},
});
});

describe('If there is no extension installed...', () => {
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@
"release:minor": "yarn run release minor",
"release:patch": "yarn run release patch",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build"
"build-storybook": "storybook build",
"prepare": "husky install"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if we used husky? (I avoid it in jotai.)

Copy link
Member

@arjunvegda arjunvegda Mar 8, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We use husky for devtools to enforce conventional commits but we don't need the prepare script. So we can safely omit this. See comment below

Copy link
Contributor Author

@PrettyCoffee PrettyCoffee Mar 8, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey, may I ask why you don't need the prepare script?
As I see it, the prepare script is required when a developer is pulling a fresh copy of the repo. It will then install the githooks you got in your .husky directory into the .git directory when executing npm i and the dev would not have to execute npx husky install by themselves.
And when you don't have the prepare script, a new contributor (like me) could commit with any message and no validation.

It probably doesn't / rarely matters for you as active maintainers, but I guess it could help new contributors :)

Or, if you don't want to enforce it and it should just be an optional convention, it also makes sense to remove it.

Copy link
Member

@arjunvegda arjunvegda Mar 8, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're absolutely right! We do need the prepare script. I was under the impression that husky auto-installs itself. It turns out they stopped doing that long ago.

},
"repository": {
"type": "git",
Expand Down
19 changes: 19 additions & 0 deletions src/utils/redux-extension/createReduxConnector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Message } from '../types';
import { ReduxExtension } from './getReduxExtension';

type ConnectResponse = ReturnType<NonNullable<ReduxExtension>['connect']>;
export type Connector = ConnectResponse & {
// FIXME https://github.com/reduxjs/redux-devtools/issues/1097
subscribe: (listener: (message: Message) => void) => (() => void) | undefined;
};

/** Wrapper for creating connections to the redux extension
* https://github.com/reduxjs/redux-devtools/blob/main/extension/docs/API/Methods.md#connectoptions
**/
export const createReduxConnector = (
extension: ReduxExtension,
name: string,
) => {
const connector = extension.connect({ name });
return connector as Connector;
};
24 changes: 24 additions & 0 deletions src/utils/redux-extension/getReduxExtension.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export type ReduxExtension = NonNullable<
typeof window.__REDUX_DEVTOOLS_EXTENSION__
> & {
disconnect?: () => void;
};

/** Returns the global redux extension object if available
* https://github.com/reduxjs/redux-devtools/blob/main/extension/docs/API/Arguments.md
*/
export const getReduxExtension = (
enabled = __DEV__,
): ReduxExtension | undefined => {
if (!enabled) {
return undefined;
}

const reduxExtension = window.__REDUX_DEVTOOLS_EXTENSION__;
if (!reduxExtension && __DEV__) {
console.warn('Please install/enable Redux devtools extension');
return undefined;
}

return reduxExtension;
};
1 change: 1 addition & 0 deletions src/utils/redux-extension/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { useReduxConnector } from './useReduxConnector';
69 changes: 69 additions & 0 deletions src/utils/redux-extension/useReduxConnector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { useEffect, useRef } from 'react';
import { Connector, createReduxConnector } from './createReduxConnector';
import { ReduxExtension, getReduxExtension } from './getReduxExtension';

type Connection = {
activeConnections: number;
connector: Connector;
};
const connections: Record<string, Connection> = {};

const hasActiveConnections = () => Object.keys(connections).length > 0;

const getConnector = (name: string, extension: ReduxExtension) => {
const existing = connections[name];
if (existing) {
existing.activeConnections += 1;
return { connector: existing.connector, shouldInit: false };
}

const connector = createReduxConnector(extension, name);
connections[name] = { activeConnections: 1, connector: connector };
return { connector, shouldInit: true };
};

const removeConnection = (name: string, extension: ReduxExtension) => {
const existing = connections[name];
if (!existing) return;

existing.activeConnections -= 1;
if (existing.activeConnections === 0) {
delete connections[name];
}
if (!hasActiveConnections()) extension.disconnect?.();
};

interface ConnectorOptions<T> {
name: string;
enabled: boolean | undefined;
initialValue: T;
}

export const useReduxConnector = <T>({
name,
enabled = __DEV__,
initialValue,
}: ConnectorOptions<T>) => {
const connectorRef = useRef<Connector>();
const firstValue = useRef(initialValue);

useEffect(() => {
const extension = getReduxExtension(enabled);
if (!extension) return;

const cleanup = () => {
connectorRef.current = undefined;
removeConnection(name, extension);
};

if (connectorRef.current) return cleanup;

const { connector, shouldInit } = getConnector(name, extension);
if (shouldInit) connector.init(firstValue.current);
connectorRef.current = connector;

return cleanup;
}, [enabled, name]);

return connectorRef;
};
79 changes: 29 additions & 50 deletions src/utils/useAtomDevtools.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useEffect, useRef } from 'react';
import { useAtom } from 'jotai/react';
import type { Atom, WritableAtom } from 'jotai/vanilla';
import { Message } from './types';
import { Atom, WritableAtom } from 'jotai/vanilla';
import { useReduxConnector } from './redux-extension';
import { useDidMount } from './useDidMount';

type DevtoolOptions = Parameters<typeof useAtom>[1] & {
name?: string;
Expand All @@ -12,40 +13,30 @@ export function useAtomDevtools<Value, Result>(
anAtom: WritableAtom<Value, [Value], Result> | Atom<Value>,
options?: DevtoolOptions,
): void {
const didMount = useDidMount();
const { enabled, name } = options || {};

let extension: typeof window['__REDUX_DEVTOOLS_EXTENSION__'] | false;

try {
extension = (enabled ?? __DEV__) && window.__REDUX_DEVTOOLS_EXTENSION__;
} catch {
// ignored
}

if (!extension) {
if (__DEV__ && enabled) {
console.warn('Please install/enable Redux devtools extension');
}
}

const [value, setValue] = useAtom(anAtom, options);

const lastValue = useRef(value);
const isTimeTraveling = useRef(false);
const devtools = useRef<
ReturnType<
NonNullable<typeof window['__REDUX_DEVTOOLS_EXTENSION__']>['connect']
> & {
shouldInit?: boolean;
}
>();

const atomName = name || anAtom.debugLabel || anAtom.toString();

const connector = useReduxConnector({
name: atomName,
enabled,
initialValue: value,
});

const subscriptionCleanup = useRef<() => void>();
useEffect(() => {
if (!extension) {
return;
}
// Only subscribe once.
// If there is an existing subscription, we don't want to create a second one.
if (subscriptionCleanup.current) return subscriptionCleanup.current;

if (!connector.current) return;

const setValueIfWritable = (value: Value) => {
if (typeof setValue === 'function') {
(setValue as (value: Value) => void)(value);
Expand All @@ -57,16 +48,7 @@ export function useAtomDevtools<Value, Result>(
);
};

devtools.current = extension.connect({ name: atomName });

const unsubscribe = (
devtools.current as unknown as {
// FIXME https://github.com/reduxjs/redux-devtools/issues/1097
subscribe: (
listener: (message: Message) => void,
) => (() => void) | undefined;
}
).subscribe((message) => {
subscriptionCleanup.current = connector.current.subscribe((message) => {
if (message.type === 'ACTION' && message.payload) {
try {
setValueIfWritable(JSON.parse(message.payload));
Expand All @@ -89,7 +71,7 @@ export function useAtomDevtools<Value, Result>(
message.type === 'DISPATCH' &&
message.payload?.type === 'COMMIT'
) {
devtools.current?.init(lastValue.current);
connector.current?.init(lastValue.current);
} else if (
message.type === 'DISPATCH' &&
message.payload?.type === 'IMPORT_STATE'
Expand All @@ -99,32 +81,29 @@ export function useAtomDevtools<Value, Result>(

computedStates.forEach(({ state }: { state: Value }, index: number) => {
if (index === 0) {
devtools.current?.init(state);
connector.current?.init(state);
} else {
setValueIfWritable(state);
}
});
}
});
devtools.current.shouldInit = true;
return unsubscribe;
}, [anAtom, extension, atomName, setValue]);

return subscriptionCleanup.current;
}, [anAtom, connector, setValue]);

useEffect(() => {
if (!devtools.current) {
return;
}
const connection = connector.current;
if (!connection || !didMount) return;

lastValue.current = value;
if (devtools.current.shouldInit) {
devtools.current.init(value);
devtools.current.shouldInit = false;
} else if (isTimeTraveling.current) {
if (isTimeTraveling.current) {
isTimeTraveling.current = false;
} else {
devtools.current.send(
connection.send(
`${atomName} - ${new Date().toLocaleString()}` as any,
value,
);
}
}, [anAtom, extension, atomName, value]);
}, [atomName, connector, didMount, value]);
}
Loading