Skip to main content

Tab Operations

This document explains how tab operations work in Blankspace, including the staged/batch pattern that ensures data consistency.

Overview​

Tabs are the pages within a space. Each space can have multiple tabs, and users can:

  • Create new tabs
  • Delete existing tabs
  • Rename tabs
  • Reorder tabs

All tab operations follow a staged pattern where changes are held locally until explicitly saved.

The Staged Pattern​

Why Staged Changes?​

Tab operations use a staged/batch pattern rather than immediate persistence for several important reasons:

  1. Navigation Spaces: Navigation page spaces don't exist in the database until the navigation editor saves. Immediate commits would fail for these spaces.

  2. Atomic Operations: Users often make multiple changes (reorder tabs, rename one, delete another) before saving. Batching ensures all changes succeed or fail together.

  3. Optimistic UI: Changes appear immediately in the UI while the actual save happens in the background when the user clicks "Save".

  4. Rollback Support: If a save fails, the local state can be reset to match the remote state.

Data Flow​

User Action (create/delete/rename/reorder)
↓
Update localSpaces (immediate, optimistic)
↓
UI reflects changes instantly
↓
User clicks "Save" button
↓
commitAllSpaceChanges() called
↓
All pending changes written to Supabase Storage
↓
remoteSpaces updated to match

Local vs Remote State​

The space store maintains two parallel state trees:

localSpaces​

The working copy that reflects the user's current edits:

localSpaces: {
[spaceId]: {
id: string;
order: string[]; // Current tab order
tabs: { // Tab configurations
[tabName]: SpaceConfig;
};
deletedTabs: string[]; // Tabs pending deletion
}
}

remoteSpaces​

The database state (source of truth):

remoteSpaces: {
[spaceId]: {
id: string;
order: string[]; // Tab order from database
tabs: { // Only tabs that have been loaded
[tabName]: SpaceConfig;
};
}
}

Key Differences​

AspectlocalSpacesremoteSpaces
Updated byUser actionsDatabase loads/commits
ContainsAll pending changesLast known DB state
deletedTabsTracks deletionsNot present
tabsAll edited tabsOnly loaded tabs

Tab Operations in Detail​

Creating a Tab​

// 1. User clicks "New Tab"
createSpaceTab(spaceId, tabName, config)

// 2. Optimistically update localSpaces
localSpaces[spaceId].order.push(tabName);
localSpaces[spaceId].tabs[tabName] = config;

// 3. UI shows new tab immediately

// 4. On "Save", commitAllSpaceChanges() uploads:
// - Tab config to spaces/{spaceId}/tabs/{tabName}
// - Updated tabOrder to spaces/{spaceId}/tabOrder

Deleting a Tab​

// 1. User clicks delete on a tab
deleteSpaceTab(spaceId, tabName)

// 2. Check if tab exists in database
const existsInDb = remoteSpaces[spaceId]?.order?.includes(tabName);

// 3. Update localSpaces
localSpaces[spaceId].order = order.filter(t => t !== tabName);
delete localSpaces[spaceId].tabs[tabName];

// 4. If exists in DB, mark for deletion
if (existsInDb) {
localSpaces[spaceId].deletedTabs.push(tabName);
}

// 5. On "Save", commitAllSpaceChanges():
// - Deletes tab files for items in deletedTabs
// - Updates tabOrder file
// - Clears deletedTabs array

Renaming a Tab​

// 1. User renames tab
renameSpaceTab(spaceId, oldName, newName, config)

// 2. Update localSpaces order
const index = order.indexOf(oldName);
order[index] = newName;

// 3. Move tab config to new key
localSpaces[spaceId].tabs[newName] = config;
delete localSpaces[spaceId].tabs[oldName];

// 4. Mark old name for deletion (if it existed in DB)
if (remoteSpaces[spaceId]?.order?.includes(oldName)) {
localSpaces[spaceId].deletedTabs.push(oldName);
}

// 5. On "Save", commitAllSpaceChanges():
// - Uploads new tab file
// - Deletes old tab file
// - Updates tabOrder

Reordering Tabs​

// 1. User drags tab to new position
updateLocalSpaceOrder(spaceId, newOrder)

// 2. Update localSpaces immediately
localSpaces[spaceId].order = newOrder;

// 3. On "Save", commitAllSpaceChanges():
// - Updates tabOrder file only
// - No tab files modified

The Commit Process​

When commitAllSpaceChanges(spaceId, network?) is called:

async function commitAllSpaceChanges(spaceId: string, network?: string) {
const localSpace = localSpaces[spaceId];

// 1. Delete tabs marked for deletion
for (const tabName of localSpace.deletedTabs) {
await deleteTabFromStorage(spaceId, tabName, network);
}

// 2. Upload all modified tabs
for (const [tabName, config] of Object.entries(localSpace.tabs)) {
if (tabWasModified(tabName)) {
await uploadTabToStorage(spaceId, tabName, config, network);
}
}

// 3. Upload updated tab order
await uploadTabOrder(spaceId, localSpace.order);

// 4. Update remoteSpaces to match
remoteSpaces[spaceId] = {
...localSpace,
deletedTabs: undefined // Not stored in remote
};

// 5. Clear deletedTabs
localSpace.deletedTabs = [];
}

Storage Structure​

Tab data is stored in Supabase Storage:

spaces/
└── {spaceId}/
├── tabOrder # JSON: { spaceId, tabOrder: string[], timestamp }
└── tabs/
├── {tabName1} # SignedFile containing SpaceConfig
├── {tabName2} # SignedFile containing SpaceConfig
└── ...

tabOrder File Format​

{
"spaceId": "uuid-here",
"tabOrder": ["Home", "Gallery", "Links"],
"timestamp": "2024-01-15T10:30:00.000Z"
}

Tab File Format​

Tab files are wrapped in a SignedFile format (see SignedFile Format):

{
"publicKey": "user-public-key",
"fileData": "{...SpaceConfig JSON...}",
"fileType": "json",
"isEncrypted": false,
"timestamp": "2024-01-15T10:30:00.000Z",
"signature": "ed25519-signature"
}

Detecting What Exists in Database​

A key challenge is knowing whether a tab exists in the database:

  • remoteSpaces[spaceId].tabs only contains tabs that have been loaded/viewed
  • remoteSpaces[spaceId].order contains all tabs that exist in the database

When determining if a deleted tab needs to be removed from storage:

// WRONG: Only checks loaded tabs
const existsInDb = remoteSpaces[spaceId]?.tabs?.[tabName] !== undefined;

// CORRECT: Checks the authoritative order list
const existsInDb = remoteSpaces[spaceId]?.order?.includes(tabName);

Reset/Cancel Changes​

When the user cancels their edits:

function resetConfig() {
// Reset local state to match remote
localSpaces[spaceId] = {
...remoteSpaces[spaceId],
deletedTabs: []
};
}

Error Handling​

If a commit fails partway through:

  1. Some tabs may have been saved, others not
  2. The deletedTabs array is NOT cleared
  3. User can retry the save
  4. Or reset to abandon changes

Future improvement: Implement transaction-like semantics with rollback.

  • src/common/data/stores/app/space/spaceStore.ts - Core store implementation
  • src/app/(spaces)/PublicSpace.tsx - Public space tab bar wiring
  • src/common/components/organisms/TabBar.tsx - Tab bar UI component