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

Sync state with URL (Share function) #117

Open
wants to merge 15 commits into
base: master
Choose a base branch
from
9 changes: 9 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"classnames": "^2.2.5",
"hash.js": "^1.1.7",
"is-my-json-valid": "^2.20.6",
"lz-string": "^1.5.0",
"oauth-1.0a": "^2.0.0",
"qs": "^6.3.0",
"react": "^16.14.0",
Expand Down
22 changes: 19 additions & 3 deletions src/lib/redux/cache.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { SERIALIZE, DESERIALIZE } from './action-types';
import { urlParamsToStateObj } from './serialize-to-url-middleware';
import { deepMerge } from '../utils';

const DAY_IN_HOURS = 24;
const HOUR_IN_MS = 3600000;
Expand All @@ -19,12 +21,26 @@ function deserialize( state, reducer ) {
}

export function loadInitialState( initialState, reducer ) {
let state = initialState;

// Look for serialized state in localStorage
const localStorageState = JSON.parse( localStorage.getItem( STORAGE_KEY ) ) || {};
if ( localStorageState._timestamp && localStorageState._timestamp + MAX_AGE < Date.now() ) {
return initialState;
if ( localStorageState._timestamp && localStorageState._timestamp + MAX_AGE >= Date.now() ) {
state = deserialize( localStorageState, reducer );
}

// Use deepMerge here to reconcile state derived from localStorage with
// enhancements from URL parameters. It ensures a comprehensive application
// state at launch by merging saved states and any state that's encoded in
// the URL. This is important when the URL provides partial state updates,
// which must be combined with existing state without loss of detail.
let urlParams = new URL( window.location.href ).searchParams;
Copy link
Member

Choose a reason for hiding this comment

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

not important, but we can also create these directly with:

const urlParams = new URLSearchParams( window.location.search );

let stateEnhancement = urlParamsToStateObj( urlParams );
if ( stateEnhancement ) {
state = deepMerge( state, stateEnhancement );
}

return deserialize( localStorageState, reducer );
return state;
}

export function persistState( store, reducer ) {
Expand Down
123 changes: 123 additions & 0 deletions src/lib/redux/serialize-to-url-middleware.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import reducers from '../../state/reducer';
import {
API_ENDPOINTS_RECEIVE,
REQUEST_SELECT_ENDPOINT,
REQUEST_SET_METHOD,
REQUEST_TRIGGER,
UI_SELECT_API,
UI_SELECT_VERSION,
SERIALIZE_URL,
DESERIALIZE_URL,
} from '../../state/actions';
import { getEndpoints } from '../../state/endpoints/selectors';
import { loadEndpoints } from '../../state/endpoints/actions';

/**
Copy link
Member

Choose a reason for hiding this comment

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

opening up with /** carries the specific meaning of starting a JSDoc block, but this is not attached to any node.

it would probably be better here to use /* and */ instead of doubling the stars, unless there's some standard I'm unaware of. if we want to emphasize the comment we can add additional visual fluff inside of it.

I couldn't find any official clarity on this, but if we want this to be a module-level docblock, maybe we should put it above the import statements?

* This lets us serialize the state to the URL.
*
* Note: Serialization is only ran on a few actions listed below (actionsThatUpdateUrl).
*
* The entire state is not serialized. Reducers are responsible for implementing
* SERIALIZE_URL and DESERIALIZE_URL actions to handle their own state if they want to
* serialize it into the URL. They don't have to serialize all keys, or serialize at all.
* If they choose to only serialize some keys, the results will be deep merged over the
* the current state stored in localStorage by cache.js.
*
**/

// Given a state, return a string that can be used as a URL query string.
export const serializeStateToURLString = ( state ) => {
const serializedState = reducers( state, { type: SERIALIZE_URL } );

const urlParams = new URLSearchParams();
for ( const [ key, value ] of Object.entries( serializedState ) ) {
if ( typeof value === 'string' && value ) {
urlParams.set( key, value );
}
}
return urlParams.toString();
};

// Given URL Params, return a state enhancement object that can be used to enhance the state.
export const urlParamsToStateObj = ( urlParams ) => {
// Convert urlParams to a plain object
let paramsObject = {};
for ( let [ key, value ] of urlParams.entries() ) {
paramsObject[ key ] = value;
}

// Let each reducer handle its own state
const deserializedState = reducers( paramsObject, { type: DESERIALIZE_URL } );
return deserializedState;
Copy link
Member

Choose a reason for hiding this comment

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

would it make sense to convert the URL parameters into Redux actions instead of app state?

if that were the case it would seem more reusable so listen for popstate events and update the app using the same mechanisms that were used to set it up originally.

alternatively, we could also create a new action that sets all parameters while clearing out the old ones. this could be used in those history events and remove the need for the deepMerge logic, which seems a bit fragile, and spread out the reducer logic into these separate places.

};

// On these actions, we compute the new URL and push it to the browser history
const actionsThatUpdateUrl = [
REQUEST_TRIGGER,
UI_SELECT_API,
UI_SELECT_VERSION,
REQUEST_SET_METHOD,
REQUEST_SELECT_ENDPOINT,
];

// On our initial load, we check the URL params to see if we need to send a request to load endpoints.
const initializeFromUrl = ( store, urlParams ) => {
const stateEnhancement = urlParamsToStateObj( urlParams );
const {
ui: { api: apiFromUrl, version: versionFromUrl },
request: { endpointPathLabeledInURL },
} = stateEnhancement;
if ( endpointPathLabeledInURL && apiFromUrl && versionFromUrl ) {
// They did send an endpointPath in the URL. In order to fill the entire
// endpoint state, we need to load all endpoints, then we can find a match after load.
const { dispatch } = store;
loadEndpoints( apiFromUrl, versionFromUrl )( dispatch );
Copy link
Member

Choose a reason for hiding this comment

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

fetching happens automatically anyway doesn't it?
and should we be loading these also in the same way with logged-out views?
seems like isInitializing is a property that would be appropriate to handle in the core app and not in this URL middleware, for example, to make the loading display of the app better.

return { isInitializing: true, endpointPathLabeledInURL };
Copy link
Member

Choose a reason for hiding this comment

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

isInitializing feels a bit generic for a very specific thing here. although I think there's value in rethinking where and how this is stored, in this particular case something like isFetchingEndpoints seems clearer if debugging or trying to repurpose it.

}
return { isInitializing: false };
};

// This middleware is responsible for serializing the state to the URL.
// It also handles a special case of loading endpoints and setting the selected endpoint.
Copy link
Member

Choose a reason for hiding this comment

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

nitpick, but if we embrace JSDoc syntax here then IDEs will use these comments to show in-place documentation when calling this function, and if we add type annotations in the @param tags then most IDEs now will highlight type errors without configuring anything and without installing TypeScript

export const serializeToUrlMiddleware = ( store ) => {
// When first loading, check the URL params to see if we need to send a request to load endpoints.
const urlParams = new URL( window.location.href ).searchParams;
let { isInitializing, endpointPathLabeledInURL } = initializeFromUrl( store, urlParams );

// The actual middleware that runs on every action.
return ( next ) => ( action ) => {
const result = next( action );

// Serialize and upate the URL.
if ( actionsThatUpdateUrl.includes( action.type ) ) {
Copy link
Member

Choose a reason for hiding this comment

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

if we include the actions here directly in a switch statement it will mirror all of the other Redux reducers and middleware, and remove the indirection the array with .includes() introduces

const state = store.getState();
const serializedState = serializeStateToURLString( state );

const url = new URL( window.location );
url.search = serializedState;
window.history.pushState( {}, '', url );
}

// Choose the correct endpoint once per load.
if ( isInitializing && action.type === API_ENDPOINTS_RECEIVE ) {
selectCorrectEndpoint( store, endpointPathLabeledInURL );
isInitializing = false;
}

return result;
};
};

const selectCorrectEndpoint = ( store, endpointPathLabeledInURL ) => {
const state = store.getState();
const endpoints = getEndpoints( state, state.ui.api, state.ui.version );
const endpoint = endpoints.find(
( { pathLabeled } ) => pathLabeled === endpointPathLabeledInURL
);
if ( endpoint ) {
store.dispatch( { type: REQUEST_SELECT_ENDPOINT, payload: { endpoint } } );
}
};


export default serializeToUrlMiddleware;
Copy link
Member

Choose a reason for hiding this comment

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

I would have expected to see a listener for popstate in this middleware file but I don't see one. are we updating the app state when someone hits the back button?

74 changes: 72 additions & 2 deletions src/lib/utils.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from 'lz-string';
const objectCtorString = Function.prototype.toString.call( Object );

export function isPlainObject( value ) {
Expand All @@ -17,8 +18,77 @@ export function isPlainObject( value ) {

const Ctor = Object.hasOwnProperty.call( proto, 'constructor' ) && proto.constructor;
return (
typeof Ctor === 'function' &&
Ctor instanceof Ctor &&
typeof Ctor === 'function' &&
Ctor instanceof Ctor &&
Function.prototype.toString.call( Ctor ) === objectCtorString
);
}

// Use this to make the URL shorter
const keyMap = {
method: 'me',
endpoint: 'ep',
pathValues: 'pv',
url: 'u',
queryParams: 'qp',
bodyParams: 'bp',
endpointPathLabeledInURL: 'epu',
version: 've',
api: 'ap',
};
Copy link
Member

Choose a reason for hiding this comment

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

is this necessary? if we expect big URLs, then this seems like a rather minor optimization which brings a fairly big reduction in readability. if we don't expect big URLs, do we need to shorten them?


// Filter and serialize part of the Redux state for URL encoding
export const serializeStateForUrl = ( state, keysToKeep ) => {
const filteredState = keysToKeep.reduce( ( obj, key ) => {
const shortKey = keyMap[ key ] || key;
if ( state.hasOwnProperty( key ) ) {
obj[ shortKey ] = state[ key ];
}
return obj;
}, {} );

const jsonString = JSON.stringify( filteredState );
return compressToEncodedURIComponent( jsonString );
};

// Deserialize the encoded string back to state object
export const deserializeStateFromUrl = ( base64String, keysToKeep ) => {
try {
if ( typeof base64String !== 'string' ) {
return {};
}
const jsonString = decompressFromEncodedURIComponent( base64String );
const parsedState = JSON.parse( jsonString );

// Validate the parsed state contains only the keys we're interested in
return keysToKeep.reduce( ( obj, key ) => {
const shortKey = keyMap[ key ] || key;
if ( parsedState.hasOwnProperty( shortKey ) ) {
obj[ key ] = parsedState[ shortKey ];
}
return obj;
}, {} );
} catch ( error ) {
console.error( 'Error deserializing state from URL:', error );
return {};
}
};

export const isObject = ( item ) => {
return item && typeof item === 'object' && ! Array.isArray( item );
};

export const deepMerge = ( target, source ) => {
let output = Object.assign( {}, target );
if ( isObject( target ) && isObject( source ) ) {
Object.keys( source ).forEach( ( key ) => {
if ( isObject( source[ key ] ) ) {
if ( ! ( key in target ) ) Object.assign( output, { [ key ]: source[ key ] } );
else output[ key ] = deepMerge( target[ key ], source[ key ] );
} else {
Object.assign( output, { [ key ]: source[ key ] } );
}
} );
}
return output;
};
3 changes: 3 additions & 0 deletions src/state/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,6 @@ export const REQUEST_RESULTS_RECEIVE = 'REQUEST_RESULTS_RECEIVE';
export const SECURITY_CHECK_FAILED = 'SECURITY_CHECK_FAILED';
export const SECURITY_RECEIVE_USER = 'SECURITY_RECEIVE_USER';
export const SECURITY_LOGOUT = 'SECURITY_LOGOUT';

export const SERIALIZE_URL = 'SERIALIZE_URL';
export const DESERIALIZE_URL = 'DESERIALIZE_URL';
3 changes: 2 additions & 1 deletion src/state/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ import thunk from 'redux-thunk';
import reducer from './reducer';
import { boot } from './security/actions';
import { loadInitialState, persistState } from '../lib/redux/cache';
import serializeToUrlMiddleware from '../lib/redux/serialize-to-url-middleware';

const store = createStore(
reducer,
loadInitialState( {}, reducer ),
applyMiddleware( thunk )
applyMiddleware( thunk, serializeToUrlMiddleware )
);
persistState( store, reducer );
store.dispatch( boot() );
Expand Down
15 changes: 15 additions & 0 deletions src/state/request/reducer.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { createReducer } from '../../lib/redux/create-reducer';
import {
SERIALIZE_URL,
DESERIALIZE_URL,
REQUEST_SET_METHOD,
REQUEST_SELECT_ENDPOINT,
REQUEST_UPDATE_URL,
Expand All @@ -10,6 +12,7 @@ import {
UI_SELECT_VERSION,
} from '../actions';
import schema from './schema';
import { serializeStateForUrl, deserializeStateFromUrl } from '../../lib/utils';

const defaultState = {
method: 'GET',
Expand All @@ -18,9 +21,19 @@ const defaultState = {
url: '',
queryParams: {},
bodyParams: {},
endpointPathLabeledInURL: '', // A key of which endpoint is selected, used for url serialization. This is a special case that requires coordination between reducers and is handled in middleware.
};

const reducer = createReducer( defaultState, {
[ SERIALIZE_URL ]: ( state ) =>
serializeStateForUrl( state, [ 'url', 'queryParams', 'pathValues', 'method', 'bodyParams', 'endpointPathLabeledInURL' ] ),
[ DESERIALIZE_URL ]: ( state ) => {
let newState = deserializeStateFromUrl( state, [ 'url', 'queryParams', 'pathValues', 'method', 'bodyParams', 'endpointPathLabeledInURL' ] );
if ( ! newState.endpointPathLabeledInURL ) {
newState.endpoint = false;
}
return newState;
},
[ REQUEST_SET_METHOD ]: ( state, { payload } ) => {
return ( {
...state,
Expand All @@ -31,6 +44,7 @@ const reducer = createReducer( defaultState, {
return ( {
...state,
endpoint,
endpointPathLabeledInURL: endpoint?.pathLabeled || '',
url: '',
} );
},
Expand Down Expand Up @@ -71,6 +85,7 @@ const reducer = createReducer( defaultState, {
return ( {
...state,
endpoint: false,
endpointPathLabeledInURL: '',
url: '',
} );
},
Expand Down
Loading