diff --git a/docs/reference/types.md b/docs/reference/types.md index 11b80403..a27483fb 100644 --- a/docs/reference/types.md +++ b/docs/reference/types.md @@ -133,6 +133,7 @@ the current page. | `search` | `Record`\<`string`, `string` \| `undefined`\> | The query string object of the current url. | [types/index.ts:358](https://github.com/thoughtbot/superglue/blob/38ea0f22384ca668ffa28ead0054dc09a0f39366/superglue/lib/types/index.ts#L358) | | `csrfToken?` | `string` | The Rails csrfToken that you can use for forms. | [types/index.ts:360](https://github.com/thoughtbot/superglue/blob/38ea0f22384ca668ffa28ead0054dc09a0f39366/superglue/lib/types/index.ts#L360) | | `assets` | `string`[] | The tracked asset digests. | [types/index.ts:362](https://github.com/thoughtbot/superglue/blob/38ea0f22384ca668ffa28ead0054dc09a0f39366/superglue/lib/types/index.ts#L362) | +| `isVisiting` | `boolean` | Flag indicating if a visit is currently in flight. | [types/index.ts:364](https://github.com/thoughtbot/superglue/blob/38ea0f22384ca668ffa28ead0054dc09a0f39366/superglue/lib/types/index.ts#L364) | *** diff --git a/docs/requests.md b/docs/requests.md index e6956055..b125194a 100644 --- a/docs/requests.md +++ b/docs/requests.md @@ -124,6 +124,32 @@ sequenceDiagram `application_visit.js`. If you need a global customization, e.g, progress bars, you can add them there. +## Tracking visit state + +The `isVisiting` boolean is available in [SuperglueState](./reference/types.md#supergluestate) +and indicates whether a visit is currently in flight. This is useful for showing loading +indicators, progress bars, or disabling UI elements while navigation is happening. + +```jsx +import { useSuperglue } from '@thoughtbot/superglue' + +export function App() { + const { isVisiting } = useSuperglue() + + return ( +
+ {isVisiting && } + +
+ ) +} +``` + +!!! note + `isVisiting` only tracks explicit `visit` calls. It does not track `remote` requests, + which can be many and concurrent. If you need to track loading state for multiple + concurrent requests, consider using Redux to track them independently. + ## The `beforeSave` callback diff --git a/superglue/lib/action_creators/requests.ts b/superglue/lib/action_creators/requests.ts index 553496df..28d8f326 100644 --- a/superglue/lib/action_creators/requests.ts +++ b/superglue/lib/action_creators/requests.ts @@ -14,6 +14,7 @@ import { copyPage, superglueError, receiveResponse, + visitEnd, } from '../actions' import { saveAndProcessPage, preparePageForSave } from './page' import { @@ -278,6 +279,9 @@ to the same page. return dispatch(saveAndProcessPage(pageKey, page)).then(() => visitMeta) }) .catch((e) => handleFetchErr(e, fetchArgs, dispatch)) + .finally(() => { + dispatch(visitEnd({ fetchArgs })) + }) } } diff --git a/superglue/lib/actions.ts b/superglue/lib/actions.ts index 7f4040ef..6d935c96 100644 --- a/superglue/lib/actions.ts +++ b/superglue/lib/actions.ts @@ -120,6 +120,25 @@ export const beforeVisit = createAction<{ fetchArgs: FetchArgs }>('@@superglue/BEFORE_VISIT') +/** + * A redux action called after a `visit` completes. This fires regardless of + * success, error, redirect, or abort. You can hook into this event in your redux + * slices like this: + * + * ``` + * import { visitEnd } from '@thoughtbot/superglue' + * + * export const exampleSlice = createSlice({ + * name: 'Example', + * initialState: {}, + * extraReducers: (builder) => { + * builder.addCase(visitEnd, (state, action) => { + * ``` + */ +export const visitEnd = createAction<{ + fetchArgs: FetchArgs +}>('@@superglue/VISIT_END') + /** * A redux action called before `remote` takes place. You can hook into this event * in your redux slices like this: diff --git a/superglue/lib/reducers/superglueReducer.ts b/superglue/lib/reducers/superglueReducer.ts index 62b3eb42..3b0ac85c 100644 --- a/superglue/lib/reducers/superglueReducer.ts +++ b/superglue/lib/reducers/superglueReducer.ts @@ -6,6 +6,8 @@ import { setCSRFToken, setActivePage, resetStore, + beforeVisit, + visitEnd, } from '../actions' import { SuperglueState } from '../types' @@ -13,6 +15,7 @@ const initialSuperglueState: SuperglueState = { currentPageKey: '', search: {}, assets: [], + isVisiting: false, } export function superglueReducer( @@ -58,5 +61,13 @@ export function superglueReducer( return { ...state, csrfToken, assets } } + if (beforeVisit.match(action)) { + return { ...state, isVisiting: true } + } + + if (visitEnd.match(action)) { + return { ...state, isVisiting: false } + } + return state } diff --git a/superglue/lib/types/store.ts b/superglue/lib/types/store.ts index 6925e8c4..9408910c 100644 --- a/superglue/lib/types/store.ts +++ b/superglue/lib/types/store.ts @@ -25,6 +25,8 @@ export interface SuperglueState { csrfToken?: string /** The tracked asset digests.*/ assets: string[] + /** Flag that says if a visit is currently in flight */ + isVisiting: boolean } /** diff --git a/superglue/spec/features/navigation.spec.jsx b/superglue/spec/features/navigation.spec.jsx index 9611c1d1..b95f8bbb 100644 --- a/superglue/spec/features/navigation.spec.jsx +++ b/superglue/spec/features/navigation.spec.jsx @@ -86,6 +86,7 @@ describe('start', () => { search: { some: '123' }, csrfToken: 'token', assets: ['123.js', '123.css'], + isVisiting: false, }, pages: { '/home?some=123': { diff --git a/superglue/spec/lib/reducers.spec.js b/superglue/spec/lib/reducers.spec.js index 058d0832..da107bfe 100644 --- a/superglue/spec/lib/reducers.spec.js +++ b/superglue/spec/lib/reducers.spec.js @@ -61,6 +61,43 @@ describe('reducers', () => { }) }) }) + + describe('SUPERGLUE_BEFORE_VISIT', () => { + it('sets isVisiting to true', () => { + const prevState = { isVisiting: false, currentPageKey: '/foo' } + const action = { + type: '@@superglue/BEFORE_VISIT', + payload: { + currentPageKey: '/foo', + fetchArgs: [{}, {}], + }, + } + const nextState = superglueReducer(prevState, action) + + expect(nextState).toEqual({ + isVisiting: true, + currentPageKey: '/foo', + }) + }) + }) + + describe('SUPERGLUE_VISIT_END', () => { + it('sets isVisiting to false', () => { + const prevState = { isVisiting: true, currentPageKey: '/foo' } + const action = { + type: '@@superglue/VISIT_END', + payload: { + fetchArgs: [{}, {}], + }, + } + const nextState = superglueReducer(prevState, action) + + expect(nextState).toEqual({ + isVisiting: false, + currentPageKey: '/foo', + }) + }) + }) }) describe('page reducer', () => { diff --git a/superglue/spec/lib/requests.spec.js b/superglue/spec/lib/requests.spec.js index 58065718..6cd6e799 100644 --- a/superglue/spec/lib/requests.spec.js +++ b/superglue/spec/lib/requests.spec.js @@ -1004,6 +1004,10 @@ describe('visit', () => { type: '@@superglue/HANDLE_GRAFT', payload: expect.any(Object), }, + { + type: '@@superglue/VISIT_END', + payload: expect.any(Object), + }, ] store @@ -1064,6 +1068,10 @@ describe('visit', () => { type: '@@superglue/HANDLE_GRAFT', payload: expect.any(Object), }, + { + type: '@@superglue/VISIT_END', + payload: expect.any(Object), + }, ] store @@ -1140,4 +1148,51 @@ describe('visit', () => { ) }) }) + + it('sets isVisiting to true on beforeVisit and false on visitEnd', () => { + const initialState = { + pages: {}, + superglue: { + assets: [], + isVisiting: false, + }, + } + + const store = buildStore(initialState) + + fetchMock.mock('https://example.com/first?format=json', rsp.visitSuccess()) + + expect(store.getState().superglue.isVisiting).toEqual(false) + + return store.dispatch(visit('/first')).then(() => { + expect(store.getState().superglue.isVisiting).toEqual(false) + }) + }) + + it('dispatches beforeVisit and visitEnd actions', () => { + const initialState = { + pages: {}, + superglue: { + assets: [], + isVisiting: false, + }, + } + + const store = buildStore(initialState) + + fetchMock.mock('https://example.com/first?format=json', rsp.visitSuccess()) + + return store.dispatch(visit('/first')).then(() => { + const actions = allSuperglueActions(store) + const beforeVisitAction = actions.find( + (a) => a.type === '@@superglue/BEFORE_VISIT' + ) + const visitEndAction = actions.find( + (a) => a.type === '@@superglue/VISIT_END' + ) + + expect(beforeVisitAction).toBeDefined() + expect(visitEndAction).toBeDefined() + }) + }) })