Summary
Refactor board navigation so that the URL is the single source of truth for which board is rendered and for the back/home trail. Redux would only keep a mirror of the last visited board.
The immediate trigger is the back-arrow double pop (#2351). The short-term fix patches the symptom; this issue addresses the design that produced it.
Why the current design is fragile
Three sources of truth describe "where the user is", and none of them owns the answer:
| Source |
Written by |
board.activeBoardId + navHistory (Redux, persisted) |
changeBoard, switchBoard, previousBoard, toRootBoard, REPLACE_BOARD, DELETE_BOARD, sync |
URL /board/:id |
history.push (folder click), history.replace (back, home, communicator switch, import, copy, sync) |
| Browser/webview history stack |
Browser back, Android hardware back (Cordova has no backbutton handler, so the webview default applies) |
UNSAFE_componentWillReceiveProps in Board.container.js compares Redux with the URL to guess which of the two changed. That guess is what breaks.
A second bug with the same cause (found by reading the code, not reproduced yet): a folder click pushes a history entry and the in-app back replaces one. After root › food › soup followed by the in-app back arrow, the webview stack is [root, food, food]. The next Android hardware back appears to do nothing and has to be pressed twice.
Constraints
- Cordova uses hash history (
src/history.js). In history v4, hash history ignores location.state ("Hash history cannot push state; it is ignored"), so depth or trail can't live in state. It has to be encoded in the URL.
- A
POP event has no direction. It can be back or forward, and hash history has no location.key to tell them apart. Keeping a Redux stack in sync through history.listen brings back the same guessing we have today.
- Board ids change after creation. Short ids become long ids during sync (
REPLACE_BOARD, pushLocalChangesToApi). Existing browser history entries keep the stale id, so the URL-driven version has to resolve unknown ids gracefully.
- Some screens outside
/board need the active board: /settings/export, Print, CommunicatorDialog and CommunicatorToolbar. Cordova cold-starts at / with no id and relies on the persisted activeBoardId. Redux still needs a last visited board, but only as a mirror of the URL.
- The URL is user-visible: shared links, public boards and deep links (
cordova-util.js pushes /board/:id).
Options
A. Current board only in the URL, and back = goBack(). This is the simplest option and makes in-app back identical to system back. However, there is no reliable way to know whether a previous board exists or how deep we are for Home, because there is no state on hash history, and goBack() can leave the board stack. ❌ Ruled out by constraint 1.
B. Full trail in the URL, e.g. /board/soup?trail=root,food. ⭐ Recommended.
- The rendered board, back, home and
canGoBack are all derived from the URL. This works on both browser and hash history.
- A reload restores the trail, so
navHistory no longer needs to be persisted.
- Folder click →
push with trail + current. Back → navigate to the parent. Home → root.
- Redux
activeBoardId becomes a mirror updated from a single place. navHistory, PREVIOUS_BOARD, TO_ROOT_BOARD, HISTORY_REMOVE_BOARD and the componentWillReceiveProps detector are all removed.
- Costs: longer URLs, stale ids in the trail (constraint 3), and shared links carrying the trail. All three are handled by ignoring or trimming an invalid
trail.
C. Keep Redux as the source, but with a single writer. A boardNavigation module updates Redux first and then always does history.replace. It's cheaper, but either loses integration with system back or keeps guessing on POP. It works as an intermediate step, not as the end state.
Blast radius (option B, about 10 files)
Board.container.js: componentDidMount board resolution, componentWillReceiveProps, handleTileClick, back/home, copy board, create parent board
Board.actions.js: previousBoard, toRootBoard, replaceHistoryWithActiveBoardId, switchActiveBoard, sync-time replace
Board.reducer.js: the navHistory cases
NavigationButtons, BoardGrid, Board.component: the navHistory prop becomes canGoBack / depth
CommunicatorToolbar, Communicator.actions.js, Import.container.js
AccessViewer can stay as is for now (it keeps its own local stack) and could adopt the same model later.
Proposed stages
- Single writer with no behavior change: introduce
boardNavigation (openFolder, goBack, goHome, switchTo) and route every navigation caller through it.
- Read from the URL: derive the rendered board and
canGoBack from match.params + trail, and mirror them into Redux from one place.
- Delete
navHistory, its actions and the detector, and drop navHistory from persistence.
- Id renames: on
REPLACE_BOARD, replace the current URL; resolve unknown ids by trimming the trail.
Each stage can be tested with createMemoryHistory / MemoryRouter, without depending on React render batching.
Open decisions
- Should the trail live in the query string (
?trail=) or in the path? The query string is recommended because it keeps /board/:id and existing links working.
- Should in-app back use
goBack() or navigate to the parent? goBack() keeps in-app back and system back identical, but only when the previous entry is the parent. That isn't the case after a deep link.
- Should Android hardware back at root leave the board stack (settings, login, exit app)?
- Should shared links carry the trail, or should it be stripped when sharing?
Summary
Refactor board navigation so that the URL is the single source of truth for which board is rendered and for the back/home trail. Redux would only keep a mirror of the last visited board.
The immediate trigger is the back-arrow double pop (#2351). The short-term fix patches the symptom; this issue addresses the design that produced it.
Why the current design is fragile
Three sources of truth describe "where the user is", and none of them owns the answer:
board.activeBoardId+navHistory(Redux, persisted)changeBoard,switchBoard,previousBoard,toRootBoard,REPLACE_BOARD,DELETE_BOARD, sync/board/:idhistory.push(folder click),history.replace(back, home, communicator switch, import, copy, sync)backbuttonhandler, so the webview default applies)UNSAFE_componentWillReceivePropsinBoard.container.jscompares Redux with the URL to guess which of the two changed. That guess is what breaks.A second bug with the same cause (found by reading the code, not reproduced yet): a folder click
pushes a history entry and the in-app backreplaces one. Afterroot › food › soupfollowed by the in-app back arrow, the webview stack is[root, food, food]. The next Android hardware back appears to do nothing and has to be pressed twice.Constraints
src/history.js). In history v4, hash history ignoreslocation.state("Hash history cannot push state; it is ignored"), so depth or trail can't live instate. It has to be encoded in the URL.POPevent has no direction. It can be back or forward, and hash history has nolocation.keyto tell them apart. Keeping a Redux stack in sync throughhistory.listenbrings back the same guessing we have today.REPLACE_BOARD,pushLocalChangesToApi). Existing browser history entries keep the stale id, so the URL-driven version has to resolve unknown ids gracefully./boardneed the active board:/settings/export, Print, CommunicatorDialog and CommunicatorToolbar. Cordova cold-starts at/with no id and relies on the persistedactiveBoardId. Redux still needs a last visited board, but only as a mirror of the URL.cordova-util.jspushes/board/:id).Options
A. Current board only in the URL, and back =
goBack(). This is the simplest option and makes in-app back identical to system back. However, there is no reliable way to know whether a previous board exists or how deep we are for Home, because there is nostateon hash history, andgoBack()can leave the board stack. ❌ Ruled out by constraint 1.B. Full trail in the URL, e.g.
/board/soup?trail=root,food. ⭐ Recommended.canGoBackare all derived from the URL. This works on both browser and hash history.navHistoryno longer needs to be persisted.pushwithtrail + current. Back → navigate to the parent. Home → root.activeBoardIdbecomes a mirror updated from a single place.navHistory,PREVIOUS_BOARD,TO_ROOT_BOARD,HISTORY_REMOVE_BOARDand thecomponentWillReceivePropsdetector are all removed.trail.C. Keep Redux as the source, but with a single writer. A
boardNavigationmodule updates Redux first and then always doeshistory.replace. It's cheaper, but either loses integration with system back or keeps guessing onPOP. It works as an intermediate step, not as the end state.Blast radius (option B, about 10 files)
Board.container.js:componentDidMountboard resolution,componentWillReceiveProps,handleTileClick, back/home, copy board, create parent boardBoard.actions.js:previousBoard,toRootBoard,replaceHistoryWithActiveBoardId,switchActiveBoard, sync-timereplaceBoard.reducer.js: thenavHistorycasesNavigationButtons,BoardGrid,Board.component: thenavHistoryprop becomescanGoBack/depthCommunicatorToolbar,Communicator.actions.js,Import.container.jsAccessViewercan stay as is for now (it keeps its own local stack) and could adopt the same model later.Proposed stages
boardNavigation(openFolder,goBack,goHome,switchTo) and route every navigation caller through it.canGoBackfrommatch.params+trail, and mirror them into Redux from one place.navHistory, its actions and the detector, and dropnavHistoryfrom persistence.REPLACE_BOARD,replacethe current URL; resolve unknown ids by trimming the trail.Each stage can be tested with
createMemoryHistory/MemoryRouter, without depending on React render batching.Open decisions
?trail=) or in the path? The query string is recommended because it keeps/board/:idand existing links working.goBack()or navigate to the parent?goBack()keeps in-app back and system back identical, but only when the previous entry is the parent. That isn't the case after a deep link.