Skip to content

Commit 00e6545

Browse files
christopherthielenmergify[bot]
authored andcommitted
feat(hooks): Add hooks: useTransitionHook, useOnStateChanged, useCurrentStateAndParams
RawParams
1 parent f01b18b commit 00e6545

10 files changed

+317
-15
lines changed
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import * as React from 'react';
2+
import { RawParams, StateDeclaration, StateService } from '@uirouter/core';
3+
import { UIRouterReact } from '../../../core';
4+
import { defer, makeTestRouter } from '../../__tests__/util';
5+
import { useCurrentStateAndParams } from '../useCurrentStateAndParams';
6+
7+
const state1 = { name: 'state1', url: '/state1/:param', params: { param: null } };
8+
const state2 = { name: 'state2', url: '/state2/:param', params: { param: null } };
9+
10+
describe('useCurrentStateAndParams', () => {
11+
let router: UIRouterReact;
12+
let routerGo: StateService['go'];
13+
let mountInRouter;
14+
let hookSpy = jest.fn();
15+
16+
function TestComponent({ spy }: { spy: (state: StateDeclaration, params: RawParams) => void }) {
17+
const current = useCurrentStateAndParams();
18+
spy(current.state, current.params);
19+
return <div />;
20+
}
21+
22+
beforeEach(() => {
23+
({ router, routerGo, mountInRouter } = makeTestRouter([state1, state2]));
24+
hookSpy = jest.fn();
25+
mountInRouter(<TestComponent spy={hookSpy} />);
26+
hookSpy.mockReset();
27+
});
28+
29+
it('returns the current state', async () => {
30+
await routerGo('state1');
31+
expect(hookSpy).toHaveBeenCalledTimes(1);
32+
expect(hookSpy.mock.calls[0][0]).toBe(state1);
33+
});
34+
35+
it('returns the current params', async () => {
36+
await routerGo('state1', { param: '123' });
37+
expect(hookSpy).toHaveBeenCalledTimes(1);
38+
expect(hookSpy.mock.calls[0][1]).toEqual(expect.objectContaining({ param: '123' }));
39+
});
40+
41+
it('returns the previous state until a pending transition is successful', async () => {
42+
await routerGo('state1', { param: '123' });
43+
expect(hookSpy).toHaveBeenCalledTimes(1);
44+
expect(hookSpy).toHaveBeenCalledWith(expect.objectContaining({ name: 'state1' }), expect.anything());
45+
46+
const { promise, resolve } = defer();
47+
router.transitionService.onStart({ to: 'state2' }, transition => promise);
48+
const goPromise = routerGo('state2', { param: '456' });
49+
50+
expect(hookSpy).toHaveBeenCalledTimes(1);
51+
resolve();
52+
await goPromise;
53+
expect(hookSpy).toHaveBeenCalledTimes(2);
54+
expect(hookSpy).toHaveBeenCalledWith(expect.objectContaining({ name: 'state2' }), expect.anything());
55+
});
56+
57+
it('returns the previous state if transition is unsuccessful', async () => {
58+
await routerGo('state1', { param: '123' });
59+
expect(hookSpy).toHaveBeenCalledTimes(1);
60+
expect(hookSpy).toHaveBeenCalledWith(expect.objectContaining({ name: 'state1' }), expect.anything());
61+
62+
const { promise, reject } = defer();
63+
router.transitionService.onStart({ to: 'state2' }, transition => promise);
64+
const goPromise = routerGo('state2', { param: '456' });
65+
66+
expect(hookSpy).toHaveBeenCalledTimes(1);
67+
try {
68+
router.stateService.defaultErrorHandler(() => null);
69+
reject();
70+
await goPromise;
71+
} catch (error) {}
72+
expect(hookSpy).toHaveBeenCalledTimes(1);
73+
});
74+
});
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import * as React from 'react';
2+
import { RawParams, StateDeclaration } from '@uirouter/core';
3+
import { defer, makeTestRouter } from '../../__tests__/util';
4+
import { useOnStateChanged } from '../useOnStateChanged';
5+
6+
const state1 = { name: 'state1', url: '/state1/:param', params: { param: null } };
7+
const state2 = { name: 'state2', url: '/state2/:param', params: { param: null } };
8+
9+
describe('useOnStateChanged', () => {
10+
let { router, routerGo, mountInRouter } = makeTestRouter([]);
11+
beforeEach(() => ({ router, routerGo, mountInRouter } = makeTestRouter([state1, state2])));
12+
let hookSpy = jest.fn();
13+
14+
function TestComponent({ spy }: { spy: (state: StateDeclaration, params: RawParams) => void }) {
15+
useOnStateChanged(spy);
16+
return <div />;
17+
}
18+
19+
beforeEach(() => {
20+
hookSpy = jest.fn();
21+
mountInRouter(<TestComponent spy={hookSpy} />);
22+
hookSpy.mockReset();
23+
});
24+
25+
it('returns the current state', async () => {
26+
await router.stateService.go('state1');
27+
expect(hookSpy).toHaveBeenCalledTimes(1);
28+
expect(hookSpy.mock.calls[0][0]).toBe(state1);
29+
});
30+
31+
it('returns the current params', async () => {
32+
await router.stateService.go('state1', { param: '123' });
33+
expect(hookSpy).toHaveBeenCalledTimes(1);
34+
expect(hookSpy.mock.calls[0][1]).toEqual(expect.objectContaining({ param: '123' }));
35+
});
36+
37+
it('returns the previous state until a pending transition is successful', async () => {
38+
await router.stateService.go('state1', { param: '123' });
39+
expect(hookSpy).toHaveBeenCalledTimes(1);
40+
expect(hookSpy).toHaveBeenCalledWith(expect.objectContaining({ name: 'state1' }), expect.anything());
41+
42+
const { promise, resolve } = defer();
43+
router.transitionService.onStart({ to: 'state2' }, transition => promise);
44+
const goPromise = router.stateService.go('state2', { param: '456' });
45+
46+
expect(hookSpy).toHaveBeenCalledTimes(1);
47+
resolve();
48+
await goPromise;
49+
expect(hookSpy).toHaveBeenCalledTimes(2);
50+
expect(hookSpy).toHaveBeenCalledWith(expect.objectContaining({ name: 'state2' }), expect.anything());
51+
});
52+
53+
it('returns the previous state if transition is unsuccessful', async () => {
54+
await router.stateService.go('state1', { param: '123' });
55+
expect(hookSpy).toHaveBeenCalledTimes(1);
56+
expect(hookSpy).toHaveBeenCalledWith(expect.objectContaining({ name: 'state1' }), expect.anything());
57+
58+
const { promise, reject } = defer();
59+
router.transitionService.onStart({ to: 'state2' }, transition => promise);
60+
const goPromise = router.stateService.go('state2', { param: '456' });
61+
62+
expect(hookSpy).toHaveBeenCalledTimes(1);
63+
try {
64+
router.stateService.defaultErrorHandler(() => null);
65+
reject();
66+
await goPromise;
67+
} catch (error) {}
68+
expect(hookSpy).toHaveBeenCalledTimes(1);
69+
});
70+
});
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import * as React from 'react';
2+
import { mount } from 'enzyme';
3+
import { useStableCallback } from '../useStableCallback';
4+
5+
describe('useStableCallback', () => {
6+
function TestComponent({ prop = 'prop', spy }: { prop?: any; spy: any }) {
7+
const unstableCallback = () => prop;
8+
const stableCallback = useStableCallback(unstableCallback);
9+
spy(unstableCallback, stableCallback);
10+
return <div />;
11+
}
12+
13+
it('returns the same stable callback reference over multiple renders', async () => {
14+
const spy = jest.fn();
15+
const wrapper = mount(<TestComponent spy={spy} />);
16+
wrapper.setProps({ prop: 'newvalue' });
17+
wrapper.setProps({ prop: 'nextvalue' });
18+
expect(spy).toHaveBeenCalledTimes(3);
19+
20+
const stableArg1 = spy.mock.calls[0][1];
21+
const stableArg2 = spy.mock.calls[1][1];
22+
const stableArg3 = spy.mock.calls[2][1];
23+
24+
expect(stableArg1).toBe(stableArg2);
25+
expect(stableArg1).toBe(stableArg3);
26+
expect(stableArg2).toBe(stableArg3);
27+
});
28+
29+
it('returns the most recent unstable callback reference over multiple renders', async () => {
30+
const spy = jest.fn();
31+
const wrapper = mount(<TestComponent spy={spy} />);
32+
wrapper.setProps({ prop: 'newvalue' });
33+
wrapper.setProps({ prop: 'nextvalue' });
34+
expect(spy).toHaveBeenCalledTimes(3);
35+
36+
const stableArg1 = spy.mock.calls[0][0];
37+
const stableArg2 = spy.mock.calls[1][0];
38+
const stableArg3 = spy.mock.calls[2][0];
39+
40+
expect(stableArg1.name).toBe('unstableCallback');
41+
expect(stableArg2.name).toBe('unstableCallback');
42+
expect(stableArg3.name).toBe('unstableCallback');
43+
44+
expect(stableArg1).not.toBe(stableArg2);
45+
expect(stableArg1).not.toBe(stableArg3);
46+
expect(stableArg2).not.toBe(stableArg3);
47+
});
48+
49+
it('allows a stable callback reference to access the latest render closure values', async () => {
50+
const spy = jest.fn();
51+
const wrapper = mount(<TestComponent spy={spy} />);
52+
wrapper.setProps({ prop: 'newvalue' });
53+
wrapper.setProps({ prop: 'nextvalue' });
54+
expect(spy).toHaveBeenCalledTimes(3);
55+
56+
const stableArg3 = spy.mock.calls[2][0];
57+
expect(stableArg3()).toBe('nextvalue');
58+
});
59+
});
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import * as React from 'react';
2+
import { makeTestRouter } from '../../__tests__/util';
3+
import { useTransitionHook } from '../useTransitionHook';
4+
5+
describe('useRouterHook', () => {
6+
let { router, routerGo, mountInRouter } = makeTestRouter([]);
7+
beforeEach(() => ({ router, routerGo, mountInRouter } = makeTestRouter([])));
8+
9+
it('registers with the correct transitionService hook on mount', async () => {
10+
function TestComponent() {
11+
useTransitionHook('onBefore', {}, () => null);
12+
return <div />;
13+
}
14+
15+
jest.spyOn(router.transitionService, 'onBefore');
16+
mountInRouter(<TestComponent />);
17+
expect(router.transitionService.onBefore).toHaveBeenCalledTimes(1);
18+
});
19+
20+
it('de-registers the hook on unmount', async () => {
21+
function TestComponent() {
22+
useTransitionHook('onBefore', {}, () => null);
23+
return <div />;
24+
}
25+
26+
const deregisterSpy = jest.fn();
27+
jest.spyOn(router.transitionService, 'onBefore').mockImplementation(() => deregisterSpy);
28+
const wrapper = mountInRouter(<TestComponent />);
29+
expect(router.transitionService.onBefore).toHaveBeenCalledTimes(1);
30+
expect(deregisterSpy).not.toHaveBeenCalled();
31+
32+
wrapper.unmount();
33+
expect(deregisterSpy).toHaveBeenCalled();
34+
});
35+
});

src/components/hooks/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
export * from './useCurrentStateAndParams';
2+
export * from './useOnStateChanged';
23
export * from './useRouter';
34
export * from './useSref';
5+
export * from './useStableCallback';
6+
export * from './useTransitionHook';
Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,17 @@
1-
import { StateParams } from '@uirouter/core';
2-
import { useEffect, useState } from 'react';
1+
import { useState } from 'react';
2+
import { RawParams, StateDeclaration } from '@uirouter/core';
3+
import { useOnStateChanged } from './useOnStateChanged';
34
import { useRouter } from './useRouter';
45

5-
export function useCurrentStateAndParams() {
6-
const uiRouter = useRouter();
7-
const { globals } = uiRouter;
8-
const [stateData, setStateData] = useState({ state: globals.current, params: globals.params });
9-
10-
useEffect(() => {
11-
const deregister = uiRouter.transitionService.onSuccess({}, transition => {
12-
const state = transition.to();
13-
const params = transition.params('to') as StateParams;
14-
setStateData({ state, params });
15-
});
16-
return () => deregister();
17-
}, [uiRouter]);
6+
/**
7+
* Returns the current state and parameter values.
8+
*
9+
* Each time the current state or parameter values change, the component will re-render with the new values.
10+
*/
11+
export function useCurrentStateAndParams(): { state: StateDeclaration; params: RawParams } {
12+
const globals = useRouter().globals;
13+
const [stateData, setStateData] = useState({ state: globals.current, params: globals.params as RawParams });
14+
useOnStateChanged((state, params) => setStateData({ state, params }));
1815

1916
return stateData;
2017
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { RawParams, StateDeclaration } from '@uirouter/core';
2+
import { useTransitionHook } from './useTransitionHook';
3+
4+
export function useOnStateChanged(onStateChangedCallback: (state: StateDeclaration, params: RawParams) => void) {
5+
useTransitionHook('onSuccess', {}, trans => onStateChangedCallback(trans.to(), trans.params('to')));
6+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { useCallback, useRef } from 'react';
2+
3+
/**
4+
* Returns a stabilized callback reference which delegates to the most recent unstable callback
5+
*
6+
* This is similar to useCallback, but allows unstableCallback to access the most recent values from the closure.
7+
* This can be useful if the callback is being stored long term, such as in the Transition Hook registry.
8+
*
9+
* Example:
10+
* ```
11+
* const latestValueFromProps = props.value
12+
* const transitionHook = useStableCallback(() => console.log(latestValueFromProps));
13+
* useEffect(() => {
14+
* const deregister = transitionService.onBefore({ exiting: 'someState' }, transitionHook);
15+
* return () => deregister();
16+
* }, []);
17+
* ```
18+
*/
19+
20+
export function useStableCallback<T extends Function>(unstableCallback: T): T {
21+
const ref = useRef<T>(unstableCallback);
22+
const callback = useCallback(function() {
23+
return ref.current && ref.current.apply(this, arguments);
24+
}, []);
25+
return (callback as unknown) as T;
26+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { curry, HookFn, StateParams, TransitionService, TransitionStateHookFn } from '@uirouter/core';
2+
import { HookMatchCriteria, HookRegOptions, TransitionHookFn } from '@uirouter/core/lib/transition/interface';
3+
import { useEffect, useState } from 'react';
4+
import { useRouter } from './useRouter';
5+
6+
type HookName = 'onBefore' | 'onStart' | 'onSuccess' | 'onError' | 'onSuccess' | 'onFinish';
7+
type StateHookName = 'onEnter' | 'onRetain' | 'onExit';
8+
9+
export function useTransitionHook(
10+
hookName: HookName,
11+
criteria: HookMatchCriteria,
12+
callback: TransitionHookFn,
13+
options?: HookRegOptions
14+
);
15+
export function useTransitionHook(
16+
hookName: StateHookName,
17+
criteria: HookMatchCriteria,
18+
callback: TransitionStateHookFn,
19+
options?: HookRegOptions
20+
);
21+
export function useTransitionHook(
22+
hookRegistrationFn: HookName | StateHookName,
23+
criteria: HookMatchCriteria,
24+
callback: TransitionHookFn | TransitionStateHookFn,
25+
options?: HookRegOptions
26+
) {
27+
const { transitionService } = useRouter();
28+
useEffect(() => {
29+
const deregister = transitionService[hookRegistrationFn](criteria, callback as any, options);
30+
return () => deregister();
31+
}, [transitionService, hookRegistrationFn]);
32+
}

0 commit comments

Comments
 (0)