Skip to content

Refactor: make the URL the single source of truth for board navigation #2352

Description

@RodriSanchez1

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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

  1. Single writer with no behavior change: introduce boardNavigation (openFolder, goBack, goHome, switchTo) and route every navigation caller through it.
  2. Read from the URL: derive the rendered board and canGoBack from match.params + trail, and mirror them into Redux from one place.
  3. Delete navHistory, its actions and the detector, and drop navHistory from persistence.
  4. 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?

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions