The React Router v4 <Prompt></Prompt> component is perfect for the use case of protecting navigation away from a partially filled out form.
But what if we want to supply our own logic in place of the default browser alert() that this component uses? React is intended for creating UIs, so it seems like a pretty reasonable use case. Digging through the issues on Prompt in the github I did not find anyone asking about this.
Does anyone know of a solution for providing custom behavior for the alert?
Although you can make use of a custom Modal component while preventing navigating between pages through Links, you can't show a custom modal while trying to close browser or reload it.
However if thats fine with you, you can make use of history.listen to and block navigation. I wrote a generic HOC for it which solves this use case.
In the below code whitelisted pathnames are the pathnames that you would want the other person to navigate to without showing the prompt
import React from 'react';
import { withRouter } from 'react-router';
import _ from 'lodash';
const navigationPromptFactory = ({ Prompt }) => {
const initialState = {
currentLocation: null,
targetLocation: null,
isOpen: false
};
class NavigationPrompt extends React.Component {
static defaultProps = {
when: true
};
state = initialState;
componentDidMount() {
this.block(this.props);
window.addEventListener('beforeunload', this.onBeforeUnload);
}
componentWillReceiveProps(nextProps) {
const {
when: nextWhen,
history: nextHistory,
whiteListedPathnames: nextWhiteListedPaths
} = nextProps;
const { when, history, whiteListedPathnames } = this.props;
if (
when !== nextWhen ||
!_.isEqual(nextHistory.location, history.location) ||
!_.isEqual(whiteListedPathnames, nextWhiteListedPaths)
) {
this.unblock();
this.block(nextProps);
}
}
componentWillUnmount() {
this.unblock();
window.removeEventListener('beforeunload', this.onBeforeUnload);
}
onBeforeUnload = e => {
const { when } = this.props;
// we can't override an onBeforeUnload dialog
// eslint-disable-next-line
// https://stackoverflow.com/questions/276660/how-can-i-override-the-onbeforeunload-dialog-and-replace-it-with-my-own
if (when) {
// support for custom message is no longer there
// https://www.chromestatus.com/feature/5349061406228480
// eslint-disable-next-line
// https://stackoverflow.com/questions/38879742/is-it-possible-to-display-a-custom-message-in-the-beforeunload-popup
// setting e.returnValue = "false" to show prompt, reference below
//https://github.com/electron/electron/issues/2481
e.returnValue = 'false';
}
};
block = props => {
const {
history,
when,
whiteListedPathnames = [],
searchQueryCheck = false
} = props;
this.unblock = history.block(targetLocation => {
const hasPathnameChanged =
history.location.pathname !== targetLocation.pathname;
const hasSearchQueryChanged =
history.location.search !== targetLocation.search;
const hasUrlChanged = searchQueryCheck
? hasPathnameChanged || hasSearchQueryChanged
: hasPathnameChanged;
const isTargetWhiteListed = whiteListedPathnames.includes(
targetLocation.pathname
);
const hasChanged =
when && hasUrlChanged && !isTargetWhiteListed;
if (hasChanged) {
this.setState({
currentLocation: history.location,
targetLocation,
isOpen: true
});
}
return !hasChanged;
});
};
onConfirm = () => {
const { history } = this.props;
const { currentLocation, targetLocation } = this.state;
this.unblock();
// replacing current location and then pushing navigates to the target otherwise not
// this is needed when the user tries to change the url manually
history.replace(currentLocation);
history.push(targetLocation);
this.setState(initialState);
};
onCancel = () => {
const { currentLocation } = this.state;
this.setState(initialState);
// Replacing the current location in case the user tried to change the url manually
this.unblock();
this.props.history.replace(currentLocation);
this.block(this.props);
};
render() {
return (
<Prompt
{...this.props}
isOpen={this.state.isOpen}
onCancel={this.onCancel}
onConfirm={this.onConfirm}
/>
);
}
}
return withRouter(NavigationPrompt);
};
export { navigationPromptFactory };
In order to use the above, you can simply provide your custom Prompt Modal like
const NavigationPrompt = navigationPromptFactory({
Prompt: AlertDialog
});
const whiteListedPathnames = [`${match.url}/abc`, match.url];
<NavigationPrompt
when={isEditingPlan}
cancelLabel={'Stay'}
confirmLabel={'Leave'}
whiteListedPathnames={whiteListedPathnames}
title={'Leave This Page'}
>
<span>
Unsaved Changes may not be saved
</span>
</NavigationPrompt>
The prompt component by default doesn't allow overriding the use of window.alert().
Here's a link to a conversation that matches your needs fairly similarly:
https://github.com/ReactTraining/react-router/issues/4635
There's a few key points in there that you can refer to, mostly just that instead of using prompt you can just make your own modal to be triggered on specific user actions. :)
Hope this helps
Here's a component using hooks to achieve block functionality, the <Prompt.../> component didn't work for me because I wanted to ignore the search on the location.
import { useEffect, useRef } from 'react';
import { useHistory } from 'react-router-dom';
interface IProps {
when: boolean;
message: string;
}
export default function RouteLeavingGuard({ when, message }: IProps) {
const history = useHistory();
const lastPathName = useRef(history.location.pathname);
useEffect(() => {
const unlisten = history.listen(({ pathname }) => lastPathName.current = pathname);
const unblock = history.block(({ pathname }) => {
if (lastPathName.current !== pathname && when) {
return message;
}
});
return () => {
unlisten();
unblock();
}
}, [history, when, message]);
return null;
}
Related
The useEffect doesn't fire on first render, but when I save the file (ctrl+s), the state updates and the results can be seen.
What I want to do is, when I'm in GameScreen, I tap on an ICON which takes me to WalletScreen, from there I can select some items/gifts (attachedGifts - in context) and after finalising I go back to previous screen i.e. GameScreen with gifts attached (attachedGifts!==null), now again when I tap ICON and go to WalletScreen it should show me the gifts that were attached so that I could un-attach them or update selection (this is being done in the useEffect below in WalletScreen), but the issue is, although my attachedGifts state is updating, the useEffect in WalletScreen does not fire immediately when navigated, when I hit ctrl+s to save the file, then I can see my selected/attached gifts in WalletScreen.
code:
const Main = () => {
return (
<GiftsProvider>
<Stack.Screen name='WalletScreen' component={WalletScreen} />
<Stack.Screen name='GameScreen' component={GameScreen} />
</GiftsProvider>
)
};
const GameScreen = () => {
const { attachedGifts } = useGifts(); //coming from context - GiftsProvider
console.log('attached gifts: ', attachedGifts);
return ...
};
const WalletScreen = () => {
const { attachedGifts } = useGifts();
useEffect(() => { // does not fire on initial render, after saving the file, then it works.
if (attachedGifts !== null) {
let selectedIndex = -1
let filteredArray = data.map(val => {
if (val.id === attachedGifts.id) {
selectedIndex = walletData.indexOf(val);
setSelectedGiftIndex(selectedIndex);
return {
...val,
isSelect: val?.isSelect ? !val?.isSelect : true,
};
} else {
return { ...val, isSelect: false };
}
});
setData(filteredArray);
}
}, [attachedGifts]);
const attachGiftsToContext = (obj) => {
dispatch(SET_GIFTS(obj));
showToast('Gifts attached successfully!');
navigation?.goBack(); // goes back to GameScreen
}
return (
// somewhere in between
<TouchableOpacity onPress={attachGiftsToContext}>ATTACH</TouchableOpacity>
)
};
context:
import React, { createContext, useContext, useMemo, useReducer } from 'react';
const GiftsReducer = (state: Object | null, action) => {
switch (action.type) {
case 'SET_GIFTS':
return action.payload;
default:
return state;
}
};
const GiftContext = createContext({});
export const GiftsProvider = ({ children }) => {
const initialGiftState: Object | null = null;
const [attachedGifts, dispatch] = useReducer(
GiftsReducer,
initialGiftState,
);
const memoedValue = useMemo(
() => ({
attachedGifts,
dispatch,
}),
[attachedGifts],
);
return (
<GiftContext.Provider value={memoedValue}>
{children}
</GiftContext.Provider>
);
};
export default function () {
return useContext(GiftContext);
}
Output of console.log in GameScreen:
attached gifts: Object {
"reciptId": "baNlCz6KFVABxYNHAHasd213Fu1",
"walletId": "KQCqSqC3cowZ987663QJboZ",
}
What could possibly be the reason behind this and how do I solve this?
EDIT
Added related code here: https://snack.expo.dev/uKfDPpNDr
From the docs
When you call useEffect in your component, this is effectively queuing
or scheduling an effect to maybe run, after the render is done.
After rendering finishes, useEffect will check the list of dependency
values against the values from the last render, and will call your
effect function if any one of them has changed.
You might want to take a different approach to this.
There is not much info, but I can try to suggest to put it into render, so it might look like this
const filterAttachedGifts = useMemo(() => ...your function from useEffect... , [attachedGitfs])
Some where in render you use "data" variable to render attached gifts, instead, put filterAttachedGifts function there.
Or run this function in component body and then render the result.
const filteredAttachedGifts = filterAttachedGifts()
It would run on first render and also would change on each attachedGifts change.
If this approach doesn't seems like something that you expected, please, provide more code and details
UPDATED
I assume that the problem is that your wallet receive attachedGifts on first render, and after it, useEffect check if that value was changed, and it doesn't, so it wouldn't run a function.
You can try to move your function from useEffect into external function and use that function in 2 places, in useEffect and in wallet state as a default value
feel free to pick up a better name instead of "getUpdatedArray"
const getUpdatedArray = () => {
const updatedArray = [...walletData];
if (attachedGifts !== null) {
let selectedIndex = -1
updatedArray = updatedArray.map((val: IWalletListDT) => {
if (val?.walletId === attachedGifts?.walletIds) {
selectedIndex = walletData.indexOf(val);
setSelectedGiftIndex(selectedIndex);
setPurchaseDetailDialog(val);
return {
...val,
isSelect: val?.isSelect ? !val?.isSelect : true,
};
} else {
return { ...val, isSelect: false };
}
});
}
return updatedArray;
}
Then use it here
const [walletData, setWalletData] = useState(getUpdatedArray());
and in your useEffect
useEffect(() => {
setWalletData(getUpdatedArray());
}, [attachedGifts]);
That update should cover the data on first render. That might be not the best solution, but it might help you. Better solution require more code\time etc.
I am trying to write a singleton class that will act as a local storage wrapper. I need this because wherever I use localstorage in my whole app, I need each of the items I set to have prefix. This prefix for sure changes, but at only one place, so wrapper seems a good idea so that in my app, I don't have to pass prefix each time I use localStorage.
Here is my wrapper.
let instance;
class LocalStorage {
constructor() {
if(instance){
return instance;
}
instance = this;
instance.cachePrefix = null
}
_getKey(key, usePrefix) {
return usePrefix ? `${this.cachePrefix}:${key}` : key;
}
setPrefix(prefix) {
this.cachePrefix = prefix
}
set(key, value, usePrefix = true) {
if(key == null) {
return
}
localStorage.setItem(this._getKey(key, usePrefix),value)
}
get(key, usePrefix = true) {
return localStorage.getItem(this._getKey(key, usePrefix));
}
}
export const LocalStorageWrapper = new LocalStorage()
Now, where I import this class and call setPrefix, this piece is located in the very parent component tree, so we can say that this setPrefix will be called the first time.
Problem: Even though I call this setPrefix in the very parent, in that very parent, I have async call and when its result gets resolved, that's when I call setPrefix. Even though this is very fast, I am still not sure that this will work all the time.. It's possible that before this async call finishes, child component might start to render and it will try to use wrapper that won't have prefix set up...
I can't use hooks, because the whole react app is written with classes.
I'd appreciate your inputs what can be done here.
UPDATE THIS IS VERY PARENT PROVIDER IN THE TREE.
import React, { useContext, useEffect, useMemo, useState } from 'react'
import PropTypes from 'prop-types'
import BN from 'bn.js'
import { useWallet as useWalletBase, UseWalletProvider } from 'use-wallet'
import { getWeb3, filterBalanceValue } from './web3-utils'
import { useWalletConnectors } from './ethereum-providers/connectors'
import { LocalStorageWrapper } from './local-storage-wrapper'
const NETWORK_TYPE_DEFAULT = 'main'
const WalletContext = React.createContext()
function WalletContextProvider({ children }) {
const {
account,
balance,
ethereum,
connector,
status,
chainId,
providerInfo,
type,
...walletBaseRest
} = useWalletBase()
console.log("========= ", type);
const [walletWeb3, setWalletWeb3] = useState(null)
const [networkType, setNetworkType] = useState(NETWORK_TYPE_DEFAULT)
const connected = useMemo(() => status === 'connected', [status])
// get web3 and networkType whenever chainId changes
useEffect(() => {
let cancel = false
if (!ethereum) {
LocalStorageWrapper.setPrefix(NETWORK_TYPE_DEFAULT)
return
}
const walletWeb3 = getWeb3(ethereum)
setWalletWeb3(walletWeb3)
walletWeb3.eth.net
.getNetworkType()
.then(networkType => {
if (!cancel) {
setNetworkType(networkType)
LocalStorageWrapper.setPrefix(networkType)
}
return null
})
.catch(() => {
setNetworkType(NETWORK_TYPE_DEFAULT)
LocalStorageWrapper.setPrefix(NETWORK_TYPE_DEFAULT)
})
return () => {
cancel = true
setWalletWeb3(null)
setNetworkType(NETWORK_TYPE_DEFAULT)
LocalStorageWrapper.setPrefix(NETWORK_TYPE_DEFAULT)
}
}, [ethereum, chainId])
const wallet = useMemo(
() => ({
account,
balance: new BN(filterBalanceValue(balance)),
ethereum,
networkType: connected ? networkType : 'main',
providerInfo: providerInfo,
web3: walletWeb3,
status,
chainId,
connected,
...walletBaseRest,
}),
[
account,
balance,
ethereum,
networkType,
providerInfo,
status,
chainId,
walletBaseRest,
walletWeb3,
connected,
]
)
return (
<WalletContext.Provider value={wallet}>{children}</WalletContext.Provider>
)
}
WalletContextProvider.propTypes = { children: PropTypes.node }
export function WalletProvider({ children }) {
return (
<UseWalletProvider connectors={useWalletConnectors}>
<WalletContextProvider>{children}</WalletContextProvider>
</UseWalletProvider>
)
}
WalletProvider.propTypes = { children: PropTypes.node }
export function useWallet() {
return useContext(WalletContext)
}
Stack : React16, ES6, Redux
I'm currently unable to figure what's wrong here. The goal here is to add dynamically an infinite number of components (one by one) when clicking on an add button.
I need to make them appear, if possible by pair or more, e.g. if I click on the ADD button, there should be 2 fields appearing each time (one select field and one textfield at the same time, for ex)
I'm able to make the components appear, with the help of Redux, and I'm also able to manage the datas correctly (everything's wired on the back of the app)
THE PROBLEM HERE :
When trying to type text in an input field, it's ALWAYS losing the focus. I've seen that each time I update my props, the whole component named MultipleInputChoiceList is mounted again, and that each fields are re-created anew. That's what I need to fix here :
EDIT : The MultipleInputChoiceList component is mounted via a Conditional Rendering HOC (It takes some values and check if they are true, if they are, it's rendering the component without touching the whole form)
ConditionalRenderingHOC.js
import React from 'react'
import {connect} from 'react-redux'
import _ from 'lodash'
const mapStateToProps = state => {
return {
form: state.form.form
}
}
const mapDispatchToProps = dispatch => {
return {
}
}
/**
* HOC Component to check conditional rendering on form component, using requireField property
* To be enhanced at will
*/
export default (WrappedComponent, formItem = {}) => {
class ConditionalRenderingHOC extends React.Component {
componentWillMount() {
//Check if all informations are available
if (formItem.requireField !== undefined) {
const requireField = formItem.requireField
if (requireField.value !== undefined &&
requireField.name !== undefined &&
requireField.field !== undefined &&
requireField.property !== undefined) {
//If everything's here let's call canBeRendered
this.canBeRendered()
}
}
}
//Check if the count of fetched values is directly linked to the number of fetched config asked, if true, return the same number
canBeRendered() {
formItem.requireField.isRendered = false
let required = formItem.requireField
let isEqual = false
if (this.props.form[required.field] !== undefined) {
let field = this.props.form[required.field]
_.forEach(field.value, (properties, index) => {
if (properties[required.name] !== undefined) {
if (properties[required.name] === required.value) {
if (properties[required.property] === required.isEqualTo) {
formItem.requireField.isRendered = true
isEqual = true
}
}
}
})
}
return isEqual
}
render() {
let isConditionMet = this.canBeRendered()
let render = null
if (isConditionMet === true) {
render = <WrappedComponent items={formItem}/>
}
return (<React.Fragment>
{render}
</React.Fragment>)
}
}
return connect(mapStateToProps, mapDispatchToProps)(ConditionalRenderingHOC)
}
The code
//Essentials
import React, { Component } from 'react'
import _ from 'lodash'
//Material UI
import TextField from 'material-ui/TextField'
import IconButton from 'material-ui/IconButton'
import AddBox from 'material-ui/svg-icons/content/add-box'
//Components
import SelectItemChoiceList from '../form/SelectItemChoiceList'
import TextFieldGeneric from './TextFieldGeneric'
//Redux
import { connect } from 'react-redux'
import { createNewField } from '../../../actions/formActions'
const mapStateToProps = (state) => {
return {
form: state.form.form
}
}
const mapDispatchToProps = (dispatch) => {
return {
createNewField: (field, state) => dispatch(createNewField(field, state))
}
}
class MultipleInputChoiceList extends Component {
constructor(props) {
super(props)
this.state = {
inputList: [],
}
}
onAddBtnClick() {
const name = this.props.items.name
/**Create a new field in Redux store, giving it some datas to display */
this.props.createNewField(this.props.form[name], this.props.form)
}
render() {
const name = this.props.items.name
/**I think the error is around this place, as it always re-render the same thing again and again */
const inputs = this.props.form[name].inputList.map((input, index) => {
switch(input) {
case 'selectfield': {
return React.createElement(SelectItemChoiceList, {
items: this.props.form[name].multipleField[index],
key:this.props.form[name].multipleField[index].name
})
}
case 'textfield': {
return React.createElement(TextFieldGeneric, {
items: this.props.form[name].multipleField[index],
index:index,
key:this.props.form[name].multipleField[index].name
})
}
default: {
break
}
}
})
return (
<div>
<IconButton onClick={this.onAddBtnClick.bind(this)}>
<AddBox />
</IconButton>
{inputs}
</div>
)
}
}
const MultipleInputChoiceListRedux = connect(mapStateToProps, mapDispatchToProps)(MultipleInputChoiceList)
export default MultipleInputChoiceListRedux
And the TextField used here :
TextFieldGeneric.js
//Essentials
import React, { Component } from 'react';
//Components
import TextField from 'material-ui/TextField'
//Redux
import { connect } from 'react-redux'
import { validateField, isValid } from '../../../actions/formActions'
const mapStateToProps = (state) => {
return {
form: state.form.form
}
}
const mapDispatchToProps = (dispatch) => {
return {
validateField: (field) => dispatch(validateField(field)),
isValid: () => dispatch(isValid())
}
}
class TextFieldGeneric extends Component {
constructor(props) {
super(props)
this.state = {
form: {},
field: {},
index: 0
}
}
componentWillMount() {
console.log(this.props)
//first, let's load those dynamic datas before rendering
let form = this.props.form
let index = this.props.index
/** Check if there's a correctly defined parent in form (taken from the name) */
let matchName = /[a-zA-Z]+/g
let origin = this.props.items.name.match(matchName)
//form.company.value = this.getCompaniesFormChoice()
this.setState({form: form, field: form[origin], index: index})
}
//setState and check validationFields if errors
handleFieldChange(event){
const name = event.target.name
const value = event.target.value
//Change value of state form field
const item = this.props.items
item.value = value
//validate each fields
this.props.validateField(item)
//validate form
this.props.isValid()
event.preventDefault()
}
render() {
const index = this.state.index
console.log(index)
return (
<React.Fragment>
<TextField
key={index}
floatingLabelText={this.state.field.multipleField[index].namefield}
name={this.state.field.multipleField[index].namefield}
floatingLabelFixed={true}
value = {this.state.field.multipleField[index].value}
onChange = {this.handleFieldChange.bind(this)}
errorText={this.state.field.multipleField[index].error === 0 ? '' : this.state.field.multipleField[index].error}
/>
</React.Fragment>
)
}
}
const TextFieldGenericRedux = connect(mapStateToProps, mapDispatchToProps)(TextFieldGeneric)
export default TextFieldGenericRedux
I also do understand that a part of the problem lies in the render method of the parent class (MultipleInputChoiceList.js) ...
Any help or comments REALLY appreciated!
As of now, I've not come to a real answer to that question, as it's a structural problem in my data (When updating a field via a Redux action, it's re-rendering the whole component). Maybe stock those data elsewhere would be a better option.
I've only used a onBlur method on the field, that dismiss the validation I want to do on each user input, but for the moment I could not think to another viable solution.
So far, the TextFieldGeneric.js looks like that :
//setState and check validationFields if errors
handleFieldChange(event){
this.setState({value: event.target.value})
event.preventDefault()
}
handleValidation(event){
const value = this.state.value
//Change value of state form field
const item = this.props.items
item.value = value
//validate each fields
this.props.validateField(item)
//validate form
this.props.isValid()
}
render() {
return (
<React.Fragment>
<TextField
name='name'
floatingLabelFixed={true}
value = {this.state.value}
onChange = {this.handleFieldChange.bind(this)}
onBlur = {this.handleValidation.bind(this)}
/>
</React.Fragment>
)
}
If anyone have another solution, I'll gladly hear it !
Just a disclaimer - my code worked when I had problematic function in my main component. After I exported it it stopped behaving as it should. My theory is because somehow props are not being updated properly.
Anyway, I have a component, which after it's clicked it starts listening on window object and sets proper store element to "true" and depending on next object clicked acts accordingly. After incorrect object is clicked, the store should revert to false, and it does, however the props are still "true" as shown on the screenshot below.
How can I solve this? Perhaps there is a way that function could take store as parameter instead of props? Or im calling actions inproperly or im missing something completely?
Code below:
Main component (relevant parts?):
import React from 'react';
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import {activate} from '../../actions/inventory'
import { setModalContent, setModalState } from '../../actions/modal';
import inventoryReducer from '../../reducers/inventory';
import {chainMechanics} from './itemMechanics/chainMechanics';
class ItemRenderer extends React.Component{
handleBoltcuttersClicked(){
this.props.activate('boltcutters', true);
setTimeout(() => chainMechanics(this.props), 100)
}
inventoryItemRender(){
let inventoryItem = null;
if(this.props.inventory.items.boltcutters){
inventoryItem = <a className={this.props.inventory.activeItem.boltcutters ? "ghost-button items active " : "ghost-button items"} href="#" id='boltcuttersId' onClick={() => this.handleBoltcuttersClicked()}>Boltcutters</a>
}
return inventoryItem;
}
render(){
let renderItems = this.inventoryItemRender();
return(
<div>
{renderItems}
</div>
)
}
}
const mapStateToProps = (state) => {
return {
level: state.level,
inventory: state.inventory
}
}
function mapDispatchToProps(dispatch) {
//dispatch w propsach
return(
bindActionCreators({activate: activate, setModalState: setModalState, setModalContent: setModalContent }, dispatch)
)
}
export default connect(mapStateToProps, mapDispatchToProps)(ItemRenderer);
File with problematic function:
import {activate} from '../../../actions/inventory'
import { setModalContent, setModalState } from '../../../actions/modal';
export function chainMechanics(props){
let clickedElement;
window.onclick = ((e)=>{
console.log(clickedElement, 'clickedelement', props.inventory.activeItem.boltcutters)
if(props.inventory.activeItem.boltcutters===true){
clickedElement = e.target;
if(clickedElement.id === 'chainChainedDoor'){
props.activate('boltcutters', false);
props.setModalContent('Chain_Broken');
props.setModalState(true);
} else if(clickedElement.id === 'boltcuttersId'){
console.log('foo')
} else {
props.activate('boltcutters', false);
props.setModalContent('Cant_Use');
props.setModalState(true);
console.log("props.inventory.activeItem.boltcutters", props.inventory.activeItem.boltcutters);
}
}
})
}
My actions:
const inventoryReducer = (state = inventoryDefaultState, action) => {
switch (action.type) {
case 'ACTIVE':
console.log(action)
return {
...state,
activeItem: {
...state.activeItem,
[action.item]: action.isActive
}
}
default:
return state;
}
}
How I configure store:
export default () => {
const store = createStore(
combineReducers({
level: levelReducer,
modal: modalReducer,
inventory: inventoryReducer,
styles: stylesReducer
}),
applyMiddleware(thunk)
)
return store;
}
I believe thats eveyrthing needed? If not please do let me know, I've been trying to make this work for a long time.
Screenshot:
You can use the React's function componentWillReceiveProps. That would trigger a rerender like this (and also make use of next props/state):
componentWillReceiveProps(next) {
console.log(next);
this.inventoryItemRender(next);
}
inventoryItemRender(next){
const inventory = next.inventory ? next.inventory : this.props.inventory;
let inventoryItem = null;
if(inventory.items.boltcutters){
inventoryItem = <a className={inventory.activeItem.boltcutters ? "ghost-button items active " : "ghost-button items"} href="#" id='boltcuttersId' onClick={(next) => this.handleBoltcuttersClicked(next)}>Boltcutters</a>
}
return inventoryItem;
}
handleBoltcuttersClicked(props){
this.props.activate('boltcutters', true);
setTimeout(() => chainMechanics(props), 100)
}
I'm building a React-Native app with some native screens, and some screens where I load the website with a WebView. Instead of the classical website navigation, I have a native drawer to switch pages.
My issue is that the website is using react-router, so it handles the URL change smoothly on browsers by loading only the necessary code. Still, when I change the URL in my WebView, it does as if I was refreshing the website, and it reloads everything, leading up to very slow navigation.
The only 'hack' I thought would be exposing a function on the website window variable to trigger a react-router 'go to'.
Any ideas?
So, after a few days of struggling, I have settled with the following solution, which I admit is slighlty obscure, but works perfectly (incl. back button, gestures):
In the React Web Application, I keep the ReactRouter history object available through the global Window object.
import { useHistory } from 'react-router'
// ...
declare global {
interface Window {
ReactRouterHistory: ReturnType<typeof useHistory>
}
}
const AppContextProvider = props => {
const history = useHistory()
// provide history object globally for use in Mobile Application
window.ReactRouterHistory = history
// ...
}
In the React Native Mobile Application, I have custom code injected to the WebView, that makes use of the history object for navigation and the application communicates with this code using messages:
webViewScript.ts
// ...
export default `
const handleMessage = (event) => {
var message = JSON.parse(event.data)
switch (message.type) {
// ...
case '${MessageTypes.NAVIGATE}':
if (message.params.uri && message.params.uri.match('${APP_URL}') && window.ReactRouterHistory) {
const url = message.params.uri.replace('${APP_URL}', '')
window.ReactRouterHistory.push(url)
}
break
}
};
!(() => {
function sendMessage(type, params) {
var message = JSON.stringify({type: type, params: params})
window.ReactNativeWebView.postMessage(message)
}
if (!window.appListenersAdded) {
window.appListenersAdded = true;
window.addEventListener('message', handleMessage)
var originalPushState = window.history.pushState
window.history.pushState = function () {
sendMessage('${MessageTypes.PUSH_STATE}', {state: arguments[0], title: arguments[1], url: arguments[2]})
originalPushState.apply(this, arguments)
}
}
})()
true
`
intercom.ts (no routing specifics here, just for generic communication)
import WebView, {WebViewMessageEvent} from 'react-native-webview'
import MessageTypes from './messageTypes'
export const sendMessage = (webview: WebView | null, type: MessageTypes, params: Record<string, unknown> = {}) => {
const src = `
window.postMessage('${JSON.stringify({type, params})}', '*')
true // Might fail silently per documentation
`
if (webview) webview.injectJavaScript(src)
}
export type Message = {
type?: MessageTypes,
params?: Record<string, unknown>
}
export type MessageHandler = (message: Message) => void
export const handleMessage = (handlers: Partial<Record<MessageTypes, MessageHandler>>) => (
(event: WebViewMessageEvent) => {
const message = JSON.parse(event.nativeEvent.data) as Message
const messageType = message.type
if (!messageType) return
const handler = handlers[messageType]
if (handler) handler(message)
}
)
export {default as script} from './webViewScript'
WebViewScreen.tsx
import {handleMessage, Message, script, sendMessage} from '../intercom'
// ...
const WebViewScreen = ({navigation, navigationStack}: WebViewScreenProps) => {
const messageHandlers = {
[MessageTypes.PUSH_STATE]: ({params}: Message) => {
if (!params) return
const {url} = params
const fullUrl = `${APP_URL}${url}`
navigationStack.push(fullUrl)
},
// ...
}
const uri = navigationStack.currentPath
// navigation solution using history object propagated through window object
useEffect(() => {
if (uri) {
sendMessage(webViewRef.current, MessageTypes.NAVIGATE, {uri})
}
}, [uri])
// this is correct! Source is never going to be updated, navigation is done using native history object, see useEffect above
// eslint-disable-next-line react-hooks/exhaustive-deps
const source = useMemo(() => ({uri}), [])
return (
<View
// ...
refreshControl={
<RefreshControl
// ...
/>
}
>
<WebView
source={source}
injectedJavaScript={script}
onMessage={handleMessage(messageHandlers)}
// ...
/>
</View>
)
}
Have you tried a combination of
UseEffect and route navigation?