The invariant
The frontend and backend pane trees must stay structurally identical. Both reducers mint pane and split ids from the same op stream, so a pane that exists on one side and not the other puts every id minted afterwards permanently out of step — the failure mode src/workspace/persistence.ts's header note exists to prevent.
Tab contents are allowed to differ: a tab that toPersistedTab returns null for is never mirrored, and the backend simply does not have it. That is fine right up until the presence of such a tab changes pane structure on one side only.
The bug
Split a pane, carrying an unmirrored tab into the new pane:
- the frontend's new pane holds that tab;
- the backend's new pane is empty, because it has no such tab.
Now open a persisted tab in that pane and close it again. The backend's pane is empty and folds. The frontend's still holds the unmirrored tab and does not. From that point the two trees disagree, and later opens and splits mint ids against different structures.
Mirroring the split with its move_tab_id reaches the same end state by a different route — the backend cannot resolve a tab it never opened, and splits into an empty pane regardless.
Reachable today
Export, import and generate tabs are in NON_PERSISTED_TYPES and have been unmirrored since #326, and they have always been draggable. App.test.tsx's "routes a pane-scoped export handler to the rendered pane's tab" (#97 review Fix 1) splits an Export tab into its own pane, so the test suite creates exactly this state today.
#364 does not introduce it, but makes it ordinary: a tab on a connection the user has not saved is also unmirrored (there is no profile to reconnect it to), and dragging one of those around is a far more everyday thing to do than dragging an export tab.
Why it needs a decision rather than a patch
Three fixes were tried on #369 and each is unsatisfactory in a different way:
| Approach |
Result |
Mirror with move_tab_id |
Backend splits into an empty pane — the current behaviour |
Mirror without move_tab_id |
Same empty pane, reached more tidily |
| Do not mirror the split |
Frontend gains a pane the backend never mints — worse, and immediate |
| Refuse the split entirely |
Trees stay identical, but splitting an export tab into its own pane stops working, and the #97 test depends on it |
Only the last actually holds the invariant, and it removes a real, tested behaviour. That is a product call: is splitting an unmirrored tab into its own pane worth keeping?
Options if it is worth keeping: give the backend a placeholder occupant so pane structure matches, or stop folding emptied panes when the frontend has not folded the corresponding one (which needs the backend to know a pane is locally non-empty).
Suggested first step
Decide whether splitting an unmirrored tab into its own pane is a behaviour to keep. If not, refusing the split is a handful of lines and the #97 test can be re-pointed at a persisted tab, which is what it is really about.
The invariant
The frontend and backend pane trees must stay structurally identical. Both reducers mint pane and split ids from the same op stream, so a pane that exists on one side and not the other puts every id minted afterwards permanently out of step — the failure mode
src/workspace/persistence.ts's header note exists to prevent.Tab contents are allowed to differ: a tab that
toPersistedTabreturns null for is never mirrored, and the backend simply does not have it. That is fine right up until the presence of such a tab changes pane structure on one side only.The bug
Split a pane, carrying an unmirrored tab into the new pane:
Now open a persisted tab in that pane and close it again. The backend's pane is empty and folds. The frontend's still holds the unmirrored tab and does not. From that point the two trees disagree, and later opens and splits mint ids against different structures.
Mirroring the split with its
move_tab_idreaches the same end state by a different route — the backend cannot resolve a tab it never opened, and splits into an empty pane regardless.Reachable today
Export, import and generate tabs are in
NON_PERSISTED_TYPESand have been unmirrored since #326, and they have always been draggable.App.test.tsx's "routes a pane-scoped export handler to the rendered pane's tab" (#97 review Fix 1) splits an Export tab into its own pane, so the test suite creates exactly this state today.#364 does not introduce it, but makes it ordinary: a tab on a connection the user has not saved is also unmirrored (there is no profile to reconnect it to), and dragging one of those around is a far more everyday thing to do than dragging an export tab.
Why it needs a decision rather than a patch
Three fixes were tried on #369 and each is unsatisfactory in a different way:
move_tab_idmove_tab_idOnly the last actually holds the invariant, and it removes a real, tested behaviour. That is a product call: is splitting an unmirrored tab into its own pane worth keeping?
Options if it is worth keeping: give the backend a placeholder occupant so pane structure matches, or stop folding emptied panes when the frontend has not folded the corresponding one (which needs the backend to know a pane is locally non-empty).
Suggested first step
Decide whether splitting an unmirrored tab into its own pane is a behaviour to keep. If not, refusing the split is a handful of lines and the #97 test can be re-pointed at a persisted tab, which is what it is really about.