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 (
+
+ )
+}
+```
+
+!!! 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()
+ })
+ })
})