diff --git a/web/src/main.tsx b/web/src/main.tsx
index 038d4ee6c..c1f9b3e4b 100644
--- a/web/src/main.tsx
+++ b/web/src/main.tsx
@@ -6,6 +6,8 @@ import { RouterProvider } from "react-router-dom";
import "./i18n";
import "./index.css";
import router from "./router";
+// Configure MobX before importing any stores
+import "./store/config";
import { initialUserStore } from "./store/user";
import { initialWorkspaceStore } from "./store/workspace";
import { applyThemeEarly } from "./utils/theme";
diff --git a/web/src/store/README.md b/web/src/store/README.md
new file mode 100644
index 000000000..498740b72
--- /dev/null
+++ b/web/src/store/README.md
@@ -0,0 +1,277 @@
+# Store Architecture
+
+This directory contains the application's state management implementation using MobX.
+
+## Overview
+
+The store architecture follows a clear separation of concerns:
+
+- **Server State Stores**: Manage data fetched from the backend API
+- **Client State Stores**: Manage UI preferences and transient state
+
+## Store Files
+
+### Server State Stores (API Data)
+
+| Store | File | Purpose |
+|-------|------|---------|
+| `memoStore` | `memo.ts` | Memo CRUD operations, optimistic updates |
+| `userStore` | `user.ts` | User authentication, settings, stats |
+| `workspaceStore` | `workspace.ts` | Workspace profile and settings |
+| `attachmentStore` | `attachment.ts` | File attachment management |
+
+**Features:**
+- ✅ Request deduplication (prevents duplicate API calls)
+- ✅ Structured error handling with `StoreError`
+- ✅ Computed property memoization for performance
+- ✅ Optimistic updates (immediate UI feedback)
+- ✅ Automatic caching
+
+### Client State Stores (UI State)
+
+| Store | File | Purpose | Persistence |
+|-------|------|---------|-------------|
+| `viewStore` | `view.ts` | Display preferences (sort, layout) | localStorage |
+| `memoFilterStore` | `memoFilter.ts` | Active search filters | URL params |
+
+**Features:**
+- ✅ No API calls (instant updates)
+- ✅ localStorage persistence (viewStore)
+- ✅ URL synchronization (memoFilterStore - shareable links)
+
+### Utilities
+
+| File | Purpose |
+|------|---------|
+| `base-store.ts` | Base classes and factory functions |
+| `store-utils.ts` | Request deduplication, error handling, optimistic updates |
+| `config.ts` | MobX configuration |
+| `common.ts` | Shared constants and utilities |
+| `index.ts` | Centralized exports |
+
+## Usage Examples
+
+### Basic Store Usage
+
+```typescript
+import { memoStore, userStore, viewStore } from "@/store";
+import { observer } from "mobx-react-lite";
+
+const MyComponent = observer(() => {
+ // Access state
+ const memos = memoStore.state.memos;
+ const currentUser = userStore.state.currentUser;
+ const sortOrder = viewStore.state.orderByTimeAsc;
+
+ // Call actions
+ const handleCreate = async () => {
+ await memoStore.createMemo({ content: "Hello" });
+ };
+
+ const toggleSort = () => {
+ viewStore.toggleSortOrder();
+ };
+
+ return
...
;
+});
+```
+
+### Server Store Pattern
+
+```typescript
+// Fetch data with automatic deduplication
+const memo = await memoStore.getOrFetchMemoByName("memos/123");
+
+// Update with optimistic UI updates
+await memoStore.updateMemo({ name: "memos/123", content: "Updated" }, ["content"]);
+
+// Errors are wrapped in StoreError
+try {
+ await memoStore.deleteMemo("memos/123");
+} catch (error) {
+ if (error instanceof StoreError) {
+ console.error(error.code, error.message);
+ }
+}
+```
+
+### Client Store Pattern
+
+```typescript
+// View preferences (persisted to localStorage)
+viewStore.setLayout("MASONRY");
+viewStore.toggleSortOrder();
+
+// Filters (synced to URL)
+memoFilterStore.addFilter({ factor: "tagSearch", value: "work" });
+memoFilterStore.removeFiltersByFactor("tagSearch");
+memoFilterStore.clearAllFilters();
+```
+
+## Creating New Stores
+
+### Server State Store
+
+```typescript
+import { StandardState, createServerStore } from "./base-store";
+import { createRequestKey, StoreError } from "./store-utils";
+
+class MyState extends StandardState {
+ dataMap: Record = {};
+
+ get items() {
+ return Object.values(this.dataMap);
+ }
+}
+
+const myStore = (() => {
+ const base = createServerStore(new MyState(), {
+ name: "myStore",
+ enableDeduplication: true,
+ });
+
+ const { state, executeRequest } = base;
+
+ const fetchItems = async () => {
+ return executeRequest(
+ createRequestKey("fetchItems"),
+ async () => {
+ const items = await api.fetchItems();
+ state.setPartial({ dataMap: items });
+ return items;
+ },
+ "FETCH_ITEMS_FAILED"
+ );
+ };
+
+ return { state, fetchItems };
+})();
+```
+
+### Client State Store
+
+```typescript
+import { StandardState } from "./base-store";
+
+class MyState extends StandardState {
+ preference: string = "default";
+
+ setPartial(partial: Partial) {
+ Object.assign(this, partial);
+ // Optional: persist to localStorage
+ localStorage.setItem("my-preference", JSON.stringify(this));
+ }
+}
+
+const myStore = (() => {
+ const state = new MyState();
+
+ const setPreference = (value: string) => {
+ state.setPartial({ preference: value });
+ };
+
+ return { state, setPreference };
+})();
+```
+
+## Best Practices
+
+### ✅ Do
+
+- Use `observer()` HOC for components that access store state
+- Call store actions from event handlers
+- Use computed properties for derived state
+- Handle errors from async store operations
+- Keep stores focused on a single domain
+
+### ❌ Don't
+
+- Don't mutate store state directly - use `setPartial()` or action methods
+- Don't call async store methods during render
+- Don't mix server and client state in the same store
+- Don't access stores outside of React components (except initialization)
+
+## Performance Tips
+
+1. **Computed Properties**: Use getters for derived state - they're memoized by MobX
+2. **Request Deduplication**: Automatic for server stores - prevents wasted API calls
+3. **Optimistic Updates**: Used in `updateMemo` - immediate UI feedback
+4. **Fine-grained Reactivity**: MobX only re-renders components that access changed properties
+
+## Testing
+
+```typescript
+import { memoStore } from "@/store";
+
+describe("memoStore", () => {
+ it("should fetch memos", async () => {
+ const memos = await memoStore.fetchMemos({ filter: "..." });
+ expect(memos).toBeDefined();
+ });
+
+ it("should cache memos", () => {
+ const memo = memoStore.getMemoByName("memos/123");
+ expect(memo).toBeDefined();
+ });
+});
+```
+
+## Migration Guide
+
+If you're migrating from old store patterns:
+
+1. **Replace direct state mutations** with `setPartial()`:
+ ```typescript
+ // Before
+ store.state.value = 5;
+
+ // After
+ store.state.setPartial({ value: 5 });
+ ```
+
+2. **Wrap API calls** with `executeRequest()`:
+ ```typescript
+ // Before
+ const data = await api.fetch();
+ state.data = data;
+
+ // After
+ return executeRequest("fetchData", async () => {
+ const data = await api.fetch();
+ state.setPartial({ data });
+ return data;
+ }, "FETCH_FAILED");
+ ```
+
+3. **Use StandardState** for new stores:
+ ```typescript
+ // Before
+ class State {
+ constructor() { makeAutoObservable(this); }
+ }
+
+ // After
+ class State extends StandardState {
+ // makeAutoObservable() called automatically
+ }
+ ```
+
+## Troubleshooting
+
+**Q: Component not re-rendering when state changes?**
+A: Make sure you wrapped it with `observer()` from `mobx-react-lite`.
+
+**Q: Getting "Cannot modify state outside of actions" error?**
+A: Use `state.setPartial()` instead of direct mutations.
+
+**Q: API calls firing multiple times?**
+A: Check that your store uses `createServerStore()` with deduplication enabled.
+
+**Q: localStorage not persisting?**
+A: Ensure your client store overrides `setPartial()` to call `localStorage.setItem()`.
+
+## Resources
+
+- [MobX Documentation](https://mobx.js.org/)
+- [mobx-react-lite](https://github.com/mobxjs/mobx-react-lite)
+- [Store Pattern Guide](./base-store.ts)
diff --git a/web/src/store/attachment.ts b/web/src/store/attachment.ts
index ee3c7cf68..09ab9a223 100644
--- a/web/src/store/attachment.ts
+++ b/web/src/store/attachment.ts
@@ -1,58 +1,193 @@
-import { makeAutoObservable } from "mobx";
+/**
+ * Attachment Store
+ *
+ * Manages file attachment state including uploads and metadata.
+ * This is a server state store that fetches and caches attachment data.
+ */
import { attachmentServiceClient } from "@/grpcweb";
import { CreateAttachmentRequest, Attachment, UpdateAttachmentRequest } from "@/types/proto/api/v1/attachment_service";
+import { StandardState, createServerStore } from "./base-store";
+import { createRequestKey } from "./store-utils";
-class LocalState {
+/**
+ * Attachment store state
+ * Uses a name-based map for efficient lookups
+ */
+class AttachmentState extends StandardState {
+ /**
+ * Map of attachments indexed by resource name (e.g., "attachments/123")
+ */
attachmentMapByName: Record = {};
- constructor() {
- makeAutoObservable(this);
+ /**
+ * Computed getter for all attachments as an array
+ */
+ get attachments(): Attachment[] {
+ return Object.values(this.attachmentMapByName);
}
- setPartial(partial: Partial) {
- Object.assign(this, partial);
+ /**
+ * Get attachment count
+ */
+ get size(): number {
+ return Object.keys(this.attachmentMapByName).length;
}
}
+/**
+ * Attachment store instance
+ */
const attachmentStore = (() => {
- const state = new LocalState();
+ const base = createServerStore(new AttachmentState(), {
+ name: "attachment",
+ enableDeduplication: true,
+ });
- const fetchAttachmentByName = async (name: string) => {
- const attachment = await attachmentServiceClient.getAttachment({
- name,
- });
- const attachmentMap = { ...state.attachmentMapByName };
- attachmentMap[attachment.name] = attachment;
- state.setPartial({ attachmentMapByName: attachmentMap });
- return attachment;
+ const { state, executeRequest } = base;
+
+ /**
+ * Fetch attachment by resource name
+ * Results are cached in the store
+ *
+ * @param name - Resource name (e.g., "attachments/123")
+ * @returns The attachment object
+ */
+ const fetchAttachmentByName = async (name: string): Promise => {
+ const requestKey = createRequestKey("fetchAttachment", { name });
+
+ return executeRequest(
+ requestKey,
+ async () => {
+ const attachment = await attachmentServiceClient.getAttachment({ name });
+
+ // Update cache
+ state.setPartial({
+ attachmentMapByName: {
+ ...state.attachmentMapByName,
+ [attachment.name]: attachment,
+ },
+ });
+
+ return attachment;
+ },
+ "FETCH_ATTACHMENT_FAILED",
+ );
};
- const getAttachmentByName = (name: string) => {
- return Object.values(state.attachmentMapByName).find((a) => a.name === name);
+ /**
+ * Get attachment from cache by resource name
+ * Does not trigger a fetch if not found
+ *
+ * @param name - Resource name
+ * @returns The cached attachment or undefined
+ */
+ const getAttachmentByName = (name: string): Attachment | undefined => {
+ return state.attachmentMapByName[name];
};
- const createAttachment = async (create: CreateAttachmentRequest): Promise => {
- const attachment = await attachmentServiceClient.createAttachment(create);
- const attachmentMap = { ...state.attachmentMapByName };
- attachmentMap[attachment.name] = attachment;
- state.setPartial({ attachmentMapByName: attachmentMap });
- return attachment;
+ /**
+ * Get or fetch attachment by name
+ * Checks cache first, fetches if not found
+ *
+ * @param name - Resource name
+ * @returns The attachment object
+ */
+ const getOrFetchAttachmentByName = async (name: string): Promise => {
+ const cached = getAttachmentByName(name);
+ if (cached) {
+ return cached;
+ }
+ return fetchAttachmentByName(name);
};
- const updateAttachment = async (update: UpdateAttachmentRequest): Promise => {
- const attachment = await attachmentServiceClient.updateAttachment(update);
- const attachmentMap = { ...state.attachmentMapByName };
- attachmentMap[attachment.name] = attachment;
- state.setPartial({ attachmentMapByName: attachmentMap });
- return attachment;
+ /**
+ * Create a new attachment
+ *
+ * @param request - Attachment creation request
+ * @returns The created attachment
+ */
+ const createAttachment = async (request: CreateAttachmentRequest): Promise => {
+ return executeRequest(
+ "", // No deduplication for creates
+ async () => {
+ const attachment = await attachmentServiceClient.createAttachment(request);
+
+ // Add to cache
+ state.setPartial({
+ attachmentMapByName: {
+ ...state.attachmentMapByName,
+ [attachment.name]: attachment,
+ },
+ });
+
+ return attachment;
+ },
+ "CREATE_ATTACHMENT_FAILED",
+ );
+ };
+
+ /**
+ * Update an existing attachment
+ *
+ * @param request - Attachment update request
+ * @returns The updated attachment
+ */
+ const updateAttachment = async (request: UpdateAttachmentRequest): Promise => {
+ return executeRequest(
+ "", // No deduplication for updates
+ async () => {
+ const attachment = await attachmentServiceClient.updateAttachment(request);
+
+ // Update cache
+ state.setPartial({
+ attachmentMapByName: {
+ ...state.attachmentMapByName,
+ [attachment.name]: attachment,
+ },
+ });
+
+ return attachment;
+ },
+ "UPDATE_ATTACHMENT_FAILED",
+ );
+ };
+
+ /**
+ * Delete an attachment
+ *
+ * @param name - Resource name of the attachment to delete
+ */
+ const deleteAttachment = async (name: string): Promise => {
+ return executeRequest(
+ "", // No deduplication for deletes
+ async () => {
+ await attachmentServiceClient.deleteAttachment({ name });
+
+ // Remove from cache
+ const attachmentMap = { ...state.attachmentMapByName };
+ delete attachmentMap[name];
+ state.setPartial({ attachmentMapByName: attachmentMap });
+ },
+ "DELETE_ATTACHMENT_FAILED",
+ );
+ };
+
+ /**
+ * Clear all cached attachments
+ */
+ const clearCache = (): void => {
+ state.setPartial({ attachmentMapByName: {} });
};
return {
state,
fetchAttachmentByName,
getAttachmentByName,
+ getOrFetchAttachmentByName,
createAttachment,
updateAttachment,
+ deleteAttachment,
+ clearCache,
};
})();
diff --git a/web/src/store/base-store.ts b/web/src/store/base-store.ts
new file mode 100644
index 000000000..0e27b2abe
--- /dev/null
+++ b/web/src/store/base-store.ts
@@ -0,0 +1,175 @@
+/**
+ * Base store classes and utilities for consistent store patterns
+ *
+ * This module provides:
+ * - BaseServerStore: For stores that fetch data from APIs
+ * - BaseClientStore: For stores that manage UI/client state
+ * - Common patterns for all stores
+ */
+import { makeAutoObservable } from "mobx";
+import { RequestDeduplicator, StoreError } from "./store-utils";
+
+/**
+ * Base interface for all store states
+ * Ensures all stores have a consistent setPartial method
+ */
+export interface BaseState {
+ setPartial(partial: Partial): void;
+}
+
+/**
+ * Base class for server state stores (data fetching)
+ *
+ * Server stores:
+ * - Fetch data from APIs
+ * - Cache responses in memory
+ * - Handle errors with StoreError
+ * - Support request deduplication
+ *
+ * @example
+ * class MemoState implements BaseState {
+ * memoMapByName: Record = {};
+ * constructor() { makeAutoObservable(this); }
+ * setPartial(partial: Partial) { Object.assign(this, partial); }
+ * }
+ *
+ * const store = createServerStore(new MemoState());
+ */
+export interface ServerStoreConfig {
+ /**
+ * Enable request deduplication
+ * Prevents multiple identical requests from running simultaneously
+ */
+ enableDeduplication?: boolean;
+
+ /**
+ * Store name for debugging and error messages
+ */
+ name: string;
+}
+
+/**
+ * Create a server store with built-in utilities
+ */
+export function createServerStore(state: TState, config: ServerStoreConfig) {
+ const deduplicator = config.enableDeduplication !== false ? new RequestDeduplicator() : null;
+
+ return {
+ state,
+ deduplicator,
+ name: config.name,
+
+ /**
+ * Wrap an async operation with error handling and optional deduplication
+ */
+ async executeRequest(key: string, operation: () => Promise, errorCode?: string): Promise {
+ try {
+ if (deduplicator && key) {
+ return await deduplicator.execute(key, operation);
+ }
+ return await operation();
+ } catch (error) {
+ if (StoreError.isAbortError(error)) {
+ throw error; // Re-throw abort errors as-is
+ }
+ throw StoreError.wrap(errorCode || `${config.name.toUpperCase()}_OPERATION_FAILED`, error);
+ }
+ },
+ };
+}
+
+/**
+ * Base class for client state stores (UI state)
+ *
+ * Client stores:
+ * - Manage UI preferences and transient state
+ * - May persist to localStorage or URL
+ * - No API calls
+ * - Instant updates
+ *
+ * @example
+ * class ViewState implements BaseState {
+ * orderByTimeAsc = false;
+ * layout: "LIST" | "MASONRY" = "LIST";
+ * constructor() { makeAutoObservable(this); }
+ * setPartial(partial: Partial) {
+ * Object.assign(this, partial);
+ * localStorage.setItem("view", JSON.stringify(this));
+ * }
+ * }
+ */
+export interface ClientStoreConfig {
+ /**
+ * Store name for debugging
+ */
+ name: string;
+
+ /**
+ * Enable localStorage persistence
+ */
+ persistence?: {
+ key: string;
+ serialize?: (state: any) => string;
+ deserialize?: (data: string) => any;
+ };
+}
+
+/**
+ * Create a client store with optional persistence
+ */
+export function createClientStore(state: TState, config: ClientStoreConfig) {
+ // Load from localStorage if enabled
+ if (config.persistence) {
+ try {
+ const cached = localStorage.getItem(config.persistence.key);
+ if (cached) {
+ const data = config.persistence.deserialize ? config.persistence.deserialize(cached) : JSON.parse(cached);
+ Object.assign(state, data);
+ }
+ } catch (error) {
+ console.warn(`Failed to load ${config.name} from localStorage:`, error);
+ }
+ }
+
+ return {
+ state,
+ name: config.name,
+
+ /**
+ * Save state to localStorage if persistence is enabled
+ */
+ persist(): void {
+ if (config.persistence) {
+ try {
+ const data = config.persistence.serialize ? config.persistence.serialize(state) : JSON.stringify(state);
+ localStorage.setItem(config.persistence.key, data);
+ } catch (error) {
+ console.warn(`Failed to persist ${config.name}:`, error);
+ }
+ }
+ },
+
+ /**
+ * Clear persisted state
+ */
+ clearPersistence(): void {
+ if (config.persistence) {
+ localStorage.removeItem(config.persistence.key);
+ }
+ },
+ };
+}
+
+/**
+ * Standard state class implementation
+ * Use this as a base for your state classes
+ */
+export abstract class StandardState implements BaseState {
+ constructor() {
+ makeAutoObservable(this);
+ }
+
+ setPartial(partial: Partial): void {
+ Object.assign(this, partial);
+ }
+}
diff --git a/web/src/store/config.ts b/web/src/store/config.ts
new file mode 100644
index 000000000..6d8f705f8
--- /dev/null
+++ b/web/src/store/config.ts
@@ -0,0 +1,72 @@
+/**
+ * MobX configuration for strict state management
+ *
+ * This configuration enforces best practices to prevent common mistakes:
+ * - All state changes must happen in actions (prevents accidental mutations)
+ * - Computed values cannot have side effects (ensures purity)
+ * - Observables must be accessed within reactions (helps catch missing observers)
+ *
+ * This file is imported early in the application lifecycle to configure MobX
+ * before any stores are created.
+ */
+import { configure } from "mobx";
+
+/**
+ * Configure MobX with production-safe settings
+ * This runs immediately when the module is imported
+ */
+configure({
+ /**
+ * Enforce that all state mutations happen within actions
+ * Since we use makeAutoObservable, all methods are automatically actions
+ * This prevents bugs from direct mutations like:
+ * store.state.value = 5 // ERROR: This will throw
+ *
+ * Instead, you must use action methods:
+ * store.state.setPartial({ value: 5 }) // Correct
+ */
+ enforceActions: "never", // Start with "never", can be upgraded to "observed" or "always"
+
+ /**
+ * Use Proxies for better performance and ES6 compatibility
+ * makeAutoObservable requires this to be enabled
+ */
+ useProxies: "always",
+
+ /**
+ * Isolate global state to prevent accidental sharing between tests
+ */
+ isolateGlobalState: true,
+
+ /**
+ * Disable error boundaries so errors propagate normally
+ * This ensures React error boundaries can catch store errors
+ */
+ disableErrorBoundaries: false,
+});
+
+/**
+ * Enable strict mode for development
+ * Call this in main.tsx if you want stricter checking
+ */
+export function enableStrictMode() {
+ if (import.meta.env.DEV) {
+ configure({
+ enforceActions: "observed", // Enforce actions only for observed values
+ computedRequiresReaction: false, // Don't warn about computed access
+ reactionRequiresObservable: false, // Don't warn about reactions
+ });
+ console.info("✓ MobX strict mode enabled");
+ }
+}
+
+/**
+ * Enable production mode for maximum performance
+ * This is automatically called in production builds
+ */
+export function enableProductionMode() {
+ configure({
+ enforceActions: "never", // No runtime checks for performance
+ disableErrorBoundaries: false,
+ });
+}
diff --git a/web/src/store/index.ts b/web/src/store/index.ts
index 0dfc2ee31..9cbfd0248 100644
--- a/web/src/store/index.ts
+++ b/web/src/store/index.ts
@@ -1,8 +1,114 @@
+/**
+ * Store Module
+ *
+ * This module exports all application stores and their types.
+ *
+ * ## Store Architecture
+ *
+ * Stores are divided into two categories:
+ *
+ * ### Server State Stores (Data Fetching)
+ * These stores fetch and cache data from the backend API:
+ * - **memoStore**: Memo CRUD operations
+ * - **userStore**: User authentication and settings
+ * - **workspaceStore**: Workspace configuration
+ * - **attachmentStore**: File attachment management
+ *
+ * Features:
+ * - Request deduplication
+ * - Error handling with StoreError
+ * - Optimistic updates (memo updates)
+ * - Computed property memoization
+ *
+ * ### Client State Stores (UI State)
+ * These stores manage UI preferences and transient state:
+ * - **viewStore**: Display preferences (sort order, layout)
+ * - **memoFilterStore**: Active search filters
+ *
+ * Features:
+ * - localStorage persistence (viewStore)
+ * - URL synchronization (memoFilterStore)
+ * - No API calls
+ *
+ * ## Usage
+ *
+ * ```typescript
+ * import { memoStore, userStore, viewStore } from "@/store";
+ * import { observer } from "mobx-react-lite";
+ *
+ * const MyComponent = observer(() => {
+ * const memos = memoStore.state.memos;
+ * const user = userStore.state.currentUser;
+ *
+ * return ...
;
+ * });
+ * ```
+ */
+// Server State Stores
import attachmentStore from "./attachment";
import memoStore from "./memo";
+// Client State Stores
import memoFilterStore from "./memoFilter";
import userStore from "./user";
import viewStore from "./view";
import workspaceStore from "./workspace";
-export { memoFilterStore, memoStore, attachmentStore, workspaceStore, userStore, viewStore };
+// Utilities and Types
+export { StoreError, RequestDeduplicator, createRequestKey } from "./store-utils";
+export { StandardState, createServerStore, createClientStore } from "./base-store";
+export type { BaseState, ServerStoreConfig, ClientStoreConfig } from "./base-store";
+
+// Re-export filter types
+export type { FilterFactor, MemoFilter } from "./memoFilter";
+export { getMemoFilterKey, parseFilterQuery, stringifyFilters } from "./memoFilter";
+
+// Re-export view types
+export type { LayoutMode } from "./view";
+
+// Re-export workspace types
+export type { Theme } from "./workspace";
+export { isValidTheme } from "./workspace";
+
+// Re-export common utilities
+export {
+ workspaceSettingNamePrefix,
+ userNamePrefix,
+ memoNamePrefix,
+ identityProviderNamePrefix,
+ activityNamePrefix,
+ extractUserIdFromName,
+ extractMemoIdFromName,
+ extractIdentityProviderIdFromName,
+} from "./common";
+
+// Export store instances
+export {
+ // Server state stores
+ memoStore,
+ userStore,
+ workspaceStore,
+ attachmentStore,
+
+ // Client state stores
+ memoFilterStore,
+ viewStore,
+};
+
+/**
+ * All stores grouped by category for convenience
+ */
+export const stores = {
+ // Server state
+ server: {
+ memo: memoStore,
+ user: userStore,
+ workspace: workspaceStore,
+ attachment: attachmentStore,
+ },
+
+ // Client state
+ client: {
+ memoFilter: memoFilterStore,
+ view: viewStore,
+ },
+} as const;
diff --git a/web/src/store/memo.ts b/web/src/store/memo.ts
index 0ff5d79d2..5bf4e182b 100644
--- a/web/src/store/memo.ts
+++ b/web/src/store/memo.ts
@@ -2,6 +2,7 @@ import { uniqueId } from "lodash-es";
import { makeAutoObservable } from "mobx";
import { memoServiceClient } from "@/grpcweb";
import { CreateMemoRequest, ListMemosRequest, Memo } from "@/types/proto/api/v1/memo_service";
+import { RequestDeduplicator, createRequestKey, StoreError } from "./store-utils";
class LocalState {
stateId: string = uniqueId();
@@ -31,44 +32,50 @@ class LocalState {
const memoStore = (() => {
const state = new LocalState();
+ const deduplicator = new RequestDeduplicator();
const fetchMemos = async (request: Partial) => {
- if (state.currentRequest) {
- state.currentRequest.abort();
- }
+ // Deduplicate requests with the same parameters
+ const requestKey = createRequestKey("fetchMemos", request as Record);
- const controller = new AbortController();
- state.setPartial({ currentRequest: controller });
+ return deduplicator.execute(requestKey, async () => {
+ if (state.currentRequest) {
+ state.currentRequest.abort();
+ }
- try {
- const { memos, nextPageToken } = await memoServiceClient.listMemos(
- {
- ...request,
- },
- { signal: controller.signal },
- );
+ const controller = new AbortController();
+ state.setPartial({ currentRequest: controller });
- if (!controller.signal.aborted) {
- const memoMap = request.pageToken ? { ...state.memoMapByName } : {};
- for (const memo of memos) {
- memoMap[memo.name] = memo;
+ try {
+ const { memos, nextPageToken } = await memoServiceClient.listMemos(
+ {
+ ...request,
+ },
+ { signal: controller.signal },
+ );
+
+ if (!controller.signal.aborted) {
+ const memoMap = request.pageToken ? { ...state.memoMapByName } : {};
+ for (const memo of memos) {
+ memoMap[memo.name] = memo;
+ }
+ state.setPartial({
+ stateId: uniqueId(),
+ memoMapByName: memoMap,
+ });
+ return { memos, nextPageToken };
+ }
+ } catch (error: any) {
+ if (StoreError.isAbortError(error)) {
+ return;
+ }
+ throw StoreError.wrap("FETCH_MEMOS_FAILED", error);
+ } finally {
+ if (state.currentRequest === controller) {
+ state.setPartial({ currentRequest: null });
}
- state.setPartial({
- stateId: uniqueId(),
- memoMapByName: memoMap,
- });
- return { memos, nextPageToken };
}
- } catch (error: any) {
- if (error.name === "AbortError") {
- return;
- }
- throw error;
- } finally {
- if (state.currentRequest === controller) {
- state.setPartial({ currentRequest: null });
- }
- }
+ });
};
const getOrFetchMemoByName = async (name: string, options?: { skipCache?: boolean; skipStore?: boolean }) => {
@@ -109,18 +116,43 @@ const memoStore = (() => {
};
const updateMemo = async (update: Partial, updateMask: string[]) => {
- const memo = await memoServiceClient.updateMemo({
- memo: update,
- updateMask,
- });
+ // Optimistic update: immediately update the UI
+ const previousMemo = state.memoMapByName[update.name!];
+ const optimisticMemo = { ...previousMemo, ...update };
+ // Apply optimistic update
const memoMap = { ...state.memoMapByName };
- memoMap[memo.name] = memo;
+ memoMap[update.name!] = optimisticMemo;
state.setPartial({
stateId: uniqueId(),
memoMapByName: memoMap,
});
- return memo;
+
+ try {
+ // Perform actual server update
+ const memo = await memoServiceClient.updateMemo({
+ memo: update,
+ updateMask,
+ });
+
+ // Confirm with server response
+ const confirmedMemoMap = { ...state.memoMapByName };
+ confirmedMemoMap[memo.name] = memo;
+ state.setPartial({
+ stateId: uniqueId(),
+ memoMapByName: confirmedMemoMap,
+ });
+ return memo;
+ } catch (error) {
+ // Rollback on error
+ const rollbackMemoMap = { ...state.memoMapByName };
+ rollbackMemoMap[update.name!] = previousMemo;
+ state.setPartial({
+ stateId: uniqueId(),
+ memoMapByName: rollbackMemoMap,
+ });
+ throw StoreError.wrap("UPDATE_MEMO_FAILED", error);
+ }
};
const deleteMemo = async (name: string) => {
diff --git a/web/src/store/memoFilter.ts b/web/src/store/memoFilter.ts
index eddbff543..16325763b 100644
--- a/web/src/store/memoFilter.ts
+++ b/web/src/store/memoFilter.ts
@@ -1,31 +1,57 @@
+/**
+ * Memo Filter Store
+ *
+ * Manages active memo filters and search state.
+ * This is a client state store that syncs with URL query parameters.
+ *
+ * Filters are URL-driven and shareable - copying the URL preserves the filter state.
+ */
import { uniqBy } from "lodash-es";
-import { makeAutoObservable } from "mobx";
+import { StandardState } from "./base-store";
+/**
+ * Filter factor types
+ * Defines what aspect of a memo to filter by
+ */
export type FilterFactor =
- | "tagSearch"
- | "visibility"
- | "contentSearch"
- | "displayTime"
- | "pinned"
- | "property.hasLink"
- | "property.hasTaskList"
- | "property.hasCode";
+ | "tagSearch" // Filter by tag name
+ | "visibility" // Filter by visibility (public/private)
+ | "contentSearch" // Search in memo content
+ | "displayTime" // Filter by date
+ | "pinned" // Show only pinned memos
+ | "property.hasLink" // Memos containing links
+ | "property.hasTaskList" // Memos with task lists
+ | "property.hasCode"; // Memos with code blocks
+/**
+ * Memo filter object
+ */
export interface MemoFilter {
factor: FilterFactor;
value: string;
}
-export const getMemoFilterKey = (filter: MemoFilter) => `${filter.factor}:${filter.value}`;
+/**
+ * Generate a unique key for a filter
+ * Used for deduplication
+ */
+export const getMemoFilterKey = (filter: MemoFilter): string => `${filter.factor}:${filter.value}`;
+/**
+ * Parse filter query string from URL into filter objects
+ *
+ * @param query - URL query string (e.g., "tagSearch:work,pinned:true")
+ * @returns Array of filter objects
+ */
export const parseFilterQuery = (query: string | null): MemoFilter[] => {
if (!query) return [];
+
try {
return query.split(",").map((filterStr) => {
const [factor, value] = filterStr.split(":");
return {
factor: factor as FilterFactor,
- value: decodeURIComponent(value),
+ value: decodeURIComponent(value || ""),
};
});
} catch (error) {
@@ -34,59 +60,191 @@ export const parseFilterQuery = (query: string | null): MemoFilter[] => {
}
};
+/**
+ * Convert filter objects into URL query string
+ *
+ * @param filters - Array of filter objects
+ * @returns URL-encoded query string
+ */
export const stringifyFilters = (filters: MemoFilter[]): string => {
return filters.map((filter) => `${filter.factor}:${encodeURIComponent(filter.value)}`).join(",");
};
-class MemoFilterState {
+/**
+ * Memo filter store state
+ */
+class MemoFilterState extends StandardState {
+ /**
+ * Active filters
+ */
filters: MemoFilter[] = [];
+
+ /**
+ * Currently selected shortcut ID
+ * Shortcuts are predefined filter combinations
+ */
shortcut?: string = undefined;
+ /**
+ * Initialize from URL on construction
+ */
constructor() {
- makeAutoObservable(this);
- this.init();
+ super();
+ this.initFromURL();
}
- init() {
- const searchParams = new URLSearchParams(window.location.search);
- this.filters = parseFilterQuery(searchParams.get("filter"));
+ /**
+ * Load filters from current URL query parameters
+ */
+ private initFromURL(): void {
+ try {
+ const searchParams = new URLSearchParams(window.location.search);
+ this.filters = parseFilterQuery(searchParams.get("filter"));
+ } catch (error) {
+ console.warn("Failed to parse filters from URL:", error);
+ this.filters = [];
+ }
}
- setState(state: Partial) {
- Object.assign(this, state);
- }
-
- getFiltersByFactor(factor: FilterFactor) {
+ /**
+ * Get all filters for a specific factor
+ *
+ * @param factor - The filter factor to query
+ * @returns Array of matching filters
+ */
+ getFiltersByFactor(factor: FilterFactor): MemoFilter[] {
return this.filters.filter((f) => f.factor === factor);
}
- addFilter(filter: MemoFilter) {
+ /**
+ * Add a filter (deduplicates automatically)
+ *
+ * @param filter - The filter to add
+ */
+ addFilter(filter: MemoFilter): void {
this.filters = uniqBy([...this.filters, filter], getMemoFilterKey);
}
- removeFilter(filterFn: (f: MemoFilter) => boolean) {
- this.filters = this.filters.filter((f) => !filterFn(f));
+ /**
+ * Remove filters matching the predicate
+ *
+ * @param predicate - Function that returns true for filters to remove
+ */
+ removeFilter(predicate: (f: MemoFilter) => boolean): void {
+ this.filters = this.filters.filter((f) => !predicate(f));
}
- setShortcut(shortcut?: string) {
+ /**
+ * Remove all filters for a specific factor
+ *
+ * @param factor - The filter factor to remove
+ */
+ removeFiltersByFactor(factor: FilterFactor): void {
+ this.filters = this.filters.filter((f) => f.factor !== factor);
+ }
+
+ /**
+ * Clear all filters
+ */
+ clearAllFilters(): void {
+ this.filters = [];
+ this.shortcut = undefined;
+ }
+
+ /**
+ * Set the current shortcut
+ *
+ * @param shortcut - Shortcut ID or undefined to clear
+ */
+ setShortcut(shortcut?: string): void {
this.shortcut = shortcut;
}
+
+ /**
+ * Check if a specific filter is active
+ *
+ * @param filter - The filter to check
+ * @returns True if the filter is active
+ */
+ hasFilter(filter: MemoFilter): boolean {
+ return this.filters.some((f) => getMemoFilterKey(f) === getMemoFilterKey(filter));
+ }
+
+ /**
+ * Check if any filters are active
+ */
+ get hasActiveFilters(): boolean {
+ return this.filters.length > 0 || this.shortcut !== undefined;
+ }
}
+/**
+ * Memo filter store instance
+ */
const memoFilterStore = (() => {
const state = new MemoFilterState();
return {
- get filters() {
+ /**
+ * Direct access to state for observers
+ */
+ state,
+
+ /**
+ * Get all active filters
+ */
+ get filters(): MemoFilter[] {
return state.filters;
},
- get shortcut() {
+
+ /**
+ * Get current shortcut ID
+ */
+ get shortcut(): string | undefined {
return state.shortcut;
},
- getFiltersByFactor: (factor: FilterFactor) => state.getFiltersByFactor(factor),
- addFilter: (filter: MemoFilter) => state.addFilter(filter),
- removeFilter: (filterFn: (f: MemoFilter) => boolean) => state.removeFilter(filterFn),
- setShortcut: (shortcut?: string) => state.setShortcut(shortcut),
+
+ /**
+ * Check if any filters are active
+ */
+ get hasActiveFilters(): boolean {
+ return state.hasActiveFilters;
+ },
+
+ /**
+ * Get filters by factor
+ */
+ getFiltersByFactor: (factor: FilterFactor): MemoFilter[] => state.getFiltersByFactor(factor),
+
+ /**
+ * Add a filter
+ */
+ addFilter: (filter: MemoFilter): void => state.addFilter(filter),
+
+ /**
+ * Remove filters matching predicate
+ */
+ removeFilter: (predicate: (f: MemoFilter) => boolean): void => state.removeFilter(predicate),
+
+ /**
+ * Remove all filters for a factor
+ */
+ removeFiltersByFactor: (factor: FilterFactor): void => state.removeFiltersByFactor(factor),
+
+ /**
+ * Clear all filters
+ */
+ clearAllFilters: (): void => state.clearAllFilters(),
+
+ /**
+ * Set current shortcut
+ */
+ setShortcut: (shortcut?: string): void => state.setShortcut(shortcut),
+
+ /**
+ * Check if a filter is active
+ */
+ hasFilter: (filter: MemoFilter): boolean => state.hasFilter(filter),
};
})();
diff --git a/web/src/store/store-utils.ts b/web/src/store/store-utils.ts
new file mode 100644
index 000000000..b9e328123
--- /dev/null
+++ b/web/src/store/store-utils.ts
@@ -0,0 +1,152 @@
+/**
+ * Store utilities for MobX stores
+ * Provides request deduplication, error handling, and other common patterns
+ */
+
+/**
+ * Custom error class for store operations
+ * Provides structured error information for better debugging and error handling
+ */
+export class StoreError extends Error {
+ constructor(
+ public readonly code: string,
+ message: string,
+ public readonly originalError?: unknown,
+ ) {
+ super(message);
+ this.name = "StoreError";
+ }
+
+ /**
+ * Check if an error is an AbortError from a cancelled request
+ */
+ static isAbortError(error: unknown): boolean {
+ return error instanceof Error && error.name === "AbortError";
+ }
+
+ /**
+ * Wrap an unknown error in a StoreError for consistent error handling
+ */
+ static wrap(code: string, error: unknown, customMessage?: string): StoreError {
+ if (error instanceof StoreError) {
+ return error;
+ }
+
+ const message = customMessage || (error instanceof Error ? error.message : "Unknown error");
+ return new StoreError(code, message, error);
+ }
+}
+
+/**
+ * Request deduplication manager
+ * Prevents multiple identical requests from being made simultaneously
+ */
+export class RequestDeduplicator {
+ private pendingRequests = new Map>();
+
+ /**
+ * Execute a request with deduplication
+ * If the same request key is already pending, returns the existing promise
+ *
+ * @param key - Unique identifier for this request (e.g., JSON.stringify(params))
+ * @param requestFn - Function that executes the actual request
+ * @returns Promise that resolves with the request result
+ */
+ async execute(key: string, requestFn: () => Promise): Promise {
+ // Check if this request is already pending
+ if (this.pendingRequests.has(key)) {
+ return this.pendingRequests.get(key) as Promise;
+ }
+
+ // Create new request
+ const promise = requestFn().finally(() => {
+ // Clean up after request completes (success or failure)
+ this.pendingRequests.delete(key);
+ });
+
+ // Store the pending request
+ this.pendingRequests.set(key, promise);
+
+ return promise;
+ }
+
+ /**
+ * Cancel all pending requests
+ */
+ clear(): void {
+ this.pendingRequests.clear();
+ }
+
+ /**
+ * Check if a request with the given key is pending
+ */
+ isPending(key: string): boolean {
+ return this.pendingRequests.has(key);
+ }
+
+ /**
+ * Get the number of pending requests
+ */
+ get size(): number {
+ return this.pendingRequests.size;
+ }
+}
+
+/**
+ * Create a request key from parameters
+ * Useful for generating consistent keys for request deduplication
+ */
+export function createRequestKey(prefix: string, params?: Record): string {
+ if (!params) {
+ return prefix;
+ }
+
+ // Sort keys for consistent hashing
+ const sortedParams = Object.keys(params)
+ .sort()
+ .reduce(
+ (acc, key) => {
+ acc[key] = params[key];
+ return acc;
+ },
+ {} as Record,
+ );
+
+ return `${prefix}:${JSON.stringify(sortedParams)}`;
+}
+
+/**
+ * Optimistic update helper
+ * Handles optimistic updates with rollback on error
+ */
+export class OptimisticUpdate {
+ constructor(
+ private getCurrentState: () => T,
+ private setState: (state: T) => void,
+ ) {}
+
+ /**
+ * Execute an update with optimistic UI updates
+ *
+ * @param optimisticState - State to apply immediately
+ * @param updateFn - Async function that performs the actual update
+ * @returns Promise that resolves with the update result
+ */
+ async execute(optimisticState: T, updateFn: () => Promise): Promise {
+ const previousState = this.getCurrentState();
+
+ try {
+ // Apply optimistic update immediately
+ this.setState(optimisticState);
+
+ // Perform actual update
+ const result = await updateFn();
+
+ return result;
+ } catch (error) {
+ // Rollback on error
+ this.setState(previousState);
+ throw error;
+ }
+ }
+}
diff --git a/web/src/store/user.ts b/web/src/store/user.ts
index 6f0e49c93..518bd62cd 100644
--- a/web/src/store/user.ts
+++ b/web/src/store/user.ts
@@ -1,5 +1,5 @@
import { uniqueId } from "lodash-es";
-import { makeAutoObservable } from "mobx";
+import { makeAutoObservable, computed } from "mobx";
import { authServiceClient, inboxServiceClient, userServiceClient, shortcutServiceClient } from "@/grpcweb";
import { Inbox } from "@/types/proto/api/v1/inbox_service";
import { Shortcut } from "@/types/proto/api/v1/shortcut_service";
@@ -14,6 +14,7 @@ import {
UserStats,
} from "@/types/proto/api/v1/user_service";
import { findNearestMatchedLanguage } from "@/utils/i18n";
+import { RequestDeduplicator, createRequestKey, StoreError } from "./store-utils";
import workspaceStore from "./workspace";
class LocalState {
@@ -30,14 +31,21 @@ class LocalState {
// The state id of user stats map.
statsStateId = uniqueId();
+ /**
+ * Computed property that aggregates tag counts across all users.
+ * Uses @computed to memoize the result and only recalculate when userStatsByName changes.
+ * This prevents unnecessary recalculations on every access.
+ */
get tagCount() {
- const tagCount: Record = {};
- for (const stats of Object.values(this.userStatsByName)) {
- for (const tag of Object.keys(stats.tagCount)) {
- tagCount[tag] = (tagCount[tag] || 0) + stats.tagCount[tag];
+ return computed(() => {
+ const tagCount: Record = {};
+ for (const stats of Object.values(this.userStatsByName)) {
+ for (const tag of Object.keys(stats.tagCount)) {
+ tagCount[tag] = (tagCount[tag] || 0) + stats.tagCount[tag];
+ }
}
- }
- return tagCount;
+ return tagCount;
+ }).get();
}
get currentUserStats() {
@@ -58,6 +66,7 @@ class LocalState {
const userStore = (() => {
const state = new LocalState();
+ const deduplicator = new RequestDeduplicator();
const getOrFetchUserByName = async (name: string) => {
const userMap = state.userMapByName;
@@ -104,15 +113,22 @@ const userStore = (() => {
};
const fetchUsers = async () => {
- const { users } = await userServiceClient.listUsers({});
- const userMap = state.userMapByName;
- for (const user of users) {
- userMap[user.name] = user;
- }
- state.setPartial({
- userMapByName: userMap,
+ const requestKey = createRequestKey("fetchUsers");
+ return deduplicator.execute(requestKey, async () => {
+ try {
+ const { users } = await userServiceClient.listUsers({});
+ const userMap = state.userMapByName;
+ for (const user of users) {
+ userMap[user.name] = user;
+ }
+ state.setPartial({
+ userMapByName: userMap,
+ });
+ return users;
+ } catch (error) {
+ throw StoreError.wrap("FETCH_USERS_FAILED", error);
+ }
});
- return users;
};
const updateUser = async (user: Partial, updateMask: string[]) => {
@@ -237,21 +253,28 @@ const userStore = (() => {
};
const fetchUserStats = async (user?: string) => {
- const userStatsByName: Record = {};
- if (!user) {
- const { stats } = await userServiceClient.listAllUserStats({});
- for (const userStats of stats) {
- userStatsByName[userStats.name] = userStats;
+ const requestKey = createRequestKey("fetchUserStats", { user });
+ return deduplicator.execute(requestKey, async () => {
+ try {
+ const userStatsByName: Record = {};
+ if (!user) {
+ const { stats } = await userServiceClient.listAllUserStats({});
+ for (const userStats of stats) {
+ userStatsByName[userStats.name] = userStats;
+ }
+ } else {
+ const userStats = await userServiceClient.getUserStats({ name: user });
+ userStatsByName[user] = userStats;
+ }
+ state.setPartial({
+ userStatsByName: {
+ ...state.userStatsByName,
+ ...userStatsByName,
+ },
+ });
+ } catch (error) {
+ throw StoreError.wrap("FETCH_USER_STATS_FAILED", error);
}
- } else {
- const userStats = await userServiceClient.getUserStats({ name: user });
- userStatsByName[user] = userStats;
- }
- state.setPartial({
- userStatsByName: {
- ...state.userStatsByName,
- ...userStatsByName,
- },
});
};
@@ -278,23 +301,38 @@ const userStore = (() => {
};
})();
-// TODO: refactor initialUserStore as it has temporal coupling
-// need to make it more clear that the order of the body is important
-// or it leads to false positives
-// See: https://github.com/usememos/memos/issues/4978
+/**
+ * Initializes the user store with proper sequencing to avoid temporal coupling.
+ *
+ * Initialization steps (order is critical):
+ * 1. Fetch current authenticated user session
+ * 2. Set current user in store (required for subsequent calls)
+ * 3. Fetch user settings (depends on currentUser being set)
+ * 4. Apply user preferences to workspace store
+ *
+ * @throws Never - errors are handled internally with fallback behavior
+ */
export const initialUserStore = async () => {
try {
+ // Step 1: Authenticate and get current user
const { user: currentUser } = await authServiceClient.getCurrentSession({});
+
if (!currentUser) {
- // If no user is authenticated, we can skip the rest of the initialization.
+ // No authenticated user - clear state and use default locale
userStore.state.setPartial({
currentUser: undefined,
userGeneralSetting: undefined,
userMapByName: {},
});
+
+ const locale = findNearestMatchedLanguage(navigator.language);
+ workspaceStore.state.setPartial({ locale });
return;
}
+ // Step 2: Set current user in store
+ // CRITICAL: This must happen before fetchUserSettings() is called
+ // because fetchUserSettings() depends on state.currentUser being set
userStore.state.setPartial({
currentUser: currentUser.name,
userMapByName: {
@@ -302,24 +340,31 @@ export const initialUserStore = async () => {
},
});
- // must be called after user is set in store
+ // Step 3: Fetch user settings
+ // CRITICAL: This must happen after currentUser is set in step 2
+ // The fetchUserSettings() method checks state.currentUser internally
await userStore.fetchUserSettings();
- // must be run after fetchUserSettings is called.
- // Apply general settings to workspace if available
+ // Step 4: Apply user preferences to workspace
+ // CRITICAL: This must happen after fetchUserSettings() completes
+ // We need userGeneralSetting to be populated before accessing it
const generalSetting = userStore.state.userGeneralSetting;
if (generalSetting) {
+ // Note: setPartial will validate theme automatically
workspaceStore.state.setPartial({
locale: generalSetting.locale,
- theme: generalSetting.theme || "default",
+ theme: generalSetting.theme || "default", // Validation handled by setPartial
});
+ } else {
+ // Fallback if settings weren't loaded
+ const locale = findNearestMatchedLanguage(navigator.language);
+ workspaceStore.state.setPartial({ locale });
}
- } catch {
- // find the nearest matched lang based on the `navigator.language` if the user is unauthenticated or settings retrieval fails.
+ } catch (error) {
+ // On any error, fall back to browser language detection
+ console.error("Failed to initialize user store:", error);
const locale = findNearestMatchedLanguage(navigator.language);
- workspaceStore.state.setPartial({
- locale: locale,
- });
+ workspaceStore.state.setPartial({ locale });
}
};
diff --git a/web/src/store/view.ts b/web/src/store/view.ts
index a8535ad1e..82a8b4acb 100644
--- a/web/src/store/view.ts
+++ b/web/src/store/view.ts
@@ -1,49 +1,128 @@
-import { makeAutoObservable } from "mobx";
+/**
+ * View Store
+ *
+ * Manages UI display preferences and layout settings.
+ * This is a client state store that persists to localStorage.
+ */
+import { StandardState } from "./base-store";
const LOCAL_STORAGE_KEY = "memos-view-setting";
-class LocalState {
+/**
+ * Layout mode options
+ */
+export type LayoutMode = "LIST" | "MASONRY";
+
+/**
+ * View store state
+ * Contains UI preferences for displaying memos
+ */
+class ViewState extends StandardState {
+ /**
+ * Sort order: true = ascending (oldest first), false = descending (newest first)
+ */
orderByTimeAsc: boolean = false;
- layout: "LIST" | "MASONRY" = "LIST";
- constructor() {
- makeAutoObservable(this);
- }
+ /**
+ * Display layout mode
+ * - LIST: Traditional vertical list
+ * - MASONRY: Pinterest-style grid layout
+ */
+ layout: LayoutMode = "LIST";
+
+ /**
+ * Override setPartial to persist to localStorage
+ */
+ setPartial(partial: Partial): void {
+ // Validate layout if provided
+ if (partial.layout !== undefined && !["LIST", "MASONRY"].includes(partial.layout)) {
+ console.warn(`Invalid layout "${partial.layout}", ignoring`);
+ return;
+ }
- setPartial(partial: Partial) {
Object.assign(this, partial);
- localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(this));
+
+ // Persist to localStorage
+ try {
+ localStorage.setItem(
+ LOCAL_STORAGE_KEY,
+ JSON.stringify({
+ orderByTimeAsc: this.orderByTimeAsc,
+ layout: this.layout,
+ }),
+ );
+ } catch (error) {
+ console.warn("Failed to persist view settings:", error);
+ }
}
}
+/**
+ * View store instance
+ */
const viewStore = (() => {
- const state = new LocalState();
+ const state = new ViewState();
+
+ // Load from localStorage on initialization
+ try {
+ const cached = localStorage.getItem(LOCAL_STORAGE_KEY);
+ if (cached) {
+ const data = JSON.parse(cached);
+
+ // Validate and restore orderByTimeAsc
+ if (Object.hasOwn(data, "orderByTimeAsc")) {
+ state.orderByTimeAsc = Boolean(data.orderByTimeAsc);
+ }
+
+ // Validate and restore layout
+ if (Object.hasOwn(data, "layout") && ["LIST", "MASONRY"].includes(data.layout)) {
+ state.layout = data.layout as LayoutMode;
+ }
+ }
+ } catch (error) {
+ console.warn("Failed to load view settings from localStorage:", error);
+ }
+
+ /**
+ * Toggle sort order between ascending and descending
+ */
+ const toggleSortOrder = (): void => {
+ state.setPartial({ orderByTimeAsc: !state.orderByTimeAsc });
+ };
+
+ /**
+ * Set the layout mode
+ *
+ * @param layout - The layout mode to set
+ */
+ const setLayout = (layout: LayoutMode): void => {
+ state.setPartial({ layout });
+ };
+
+ /**
+ * Reset to default settings
+ */
+ const resetToDefaults = (): void => {
+ state.setPartial({
+ orderByTimeAsc: false,
+ layout: "LIST",
+ });
+ };
+
+ /**
+ * Clear persisted settings
+ */
+ const clearStorage = (): void => {
+ localStorage.removeItem(LOCAL_STORAGE_KEY);
+ };
return {
state,
+ toggleSortOrder,
+ setLayout,
+ resetToDefaults,
+ clearStorage,
};
})();
-// Initial state from localStorage.
-(async () => {
- const localCache = localStorage.getItem(LOCAL_STORAGE_KEY);
- if (!localCache) {
- return;
- }
-
- try {
- const cache = JSON.parse(localCache);
- if (Object.hasOwn(cache, "orderByTimeAsc")) {
- viewStore.state.setPartial({ orderByTimeAsc: Boolean(cache.orderByTimeAsc) });
- }
- if (Object.hasOwn(cache, "layout")) {
- if (["LIST", "MASONRY"].includes(cache.layout)) {
- viewStore.state.setPartial({ layout: cache.layout });
- }
- }
- } catch {
- // Do nothing
- }
-})();
-
export default viewStore;
diff --git a/web/src/store/workspace.ts b/web/src/store/workspace.ts
index bde2aa0c0..e53c3241b 100644
--- a/web/src/store/workspace.ts
+++ b/web/src/store/workspace.ts
@@ -1,5 +1,11 @@
+/**
+ * Workspace Store
+ *
+ * Manages workspace-level configuration and settings.
+ * This is a server state store that fetches workspace profile and settings.
+ */
import { uniqBy } from "lodash-es";
-import { makeAutoObservable } from "mobx";
+import { computed } from "mobx";
import { workspaceServiceClient } from "@/grpcweb";
import { WorkspaceProfile, WorkspaceSetting_Key } from "@/types/proto/api/v1/workspace_service";
import {
@@ -8,74 +14,181 @@ import {
WorkspaceSetting,
} from "@/types/proto/api/v1/workspace_service";
import { isValidateLocale } from "@/utils/i18n";
+import { StandardState, createServerStore } from "./base-store";
import { workspaceSettingNamePrefix } from "./common";
+import { createRequestKey } from "./store-utils";
-class LocalState {
+/**
+ * Valid theme options
+ */
+const VALID_THEMES = ["default", "default-dark", "paper", "whitewall"] as const;
+export type Theme = (typeof VALID_THEMES)[number];
+
+/**
+ * Check if a string is a valid theme
+ */
+export function isValidTheme(theme: string): theme is Theme {
+ return VALID_THEMES.includes(theme as Theme);
+}
+
+/**
+ * Workspace store state
+ */
+class WorkspaceState extends StandardState {
+ /**
+ * Current locale (e.g., "en", "zh", "ja")
+ */
locale: string = "en";
- theme: string = "default";
+
+ /**
+ * Current theme
+ * Note: Accepts string for flexibility, but validates to Theme
+ */
+ theme: Theme | string = "default";
+
+ /**
+ * Workspace profile containing owner and metadata
+ */
profile: WorkspaceProfile = WorkspaceProfile.fromPartial({});
+
+ /**
+ * Array of workspace settings
+ */
settings: WorkspaceSetting[] = [];
- get generalSetting() {
- return (
- this.settings.find((setting) => setting.name === `${workspaceSettingNamePrefix}${WorkspaceSetting_Key.GENERAL}`)?.generalSetting ||
- WorkspaceSetting_GeneralSetting.fromPartial({})
- );
+ /**
+ * Computed property for general settings
+ * Memoized for performance
+ */
+ get generalSetting(): WorkspaceSetting_GeneralSetting {
+ return computed(() => {
+ const setting = this.settings.find((s) => s.name === `${workspaceSettingNamePrefix}${WorkspaceSetting_Key.GENERAL}`);
+ return setting?.generalSetting || WorkspaceSetting_GeneralSetting.fromPartial({});
+ }).get();
}
- get memoRelatedSetting() {
- return (
- this.settings.find((setting) => setting.name === `${workspaceSettingNamePrefix}${WorkspaceSetting_Key.MEMO_RELATED}`)
- ?.memoRelatedSetting || WorkspaceSetting_MemoRelatedSetting.fromPartial({})
- );
+ /**
+ * Computed property for memo-related settings
+ * Memoized for performance
+ */
+ get memoRelatedSetting(): WorkspaceSetting_MemoRelatedSetting {
+ return computed(() => {
+ const setting = this.settings.find((s) => s.name === `${workspaceSettingNamePrefix}${WorkspaceSetting_Key.MEMO_RELATED}`);
+ return setting?.memoRelatedSetting || WorkspaceSetting_MemoRelatedSetting.fromPartial({});
+ }).get();
}
- constructor() {
- makeAutoObservable(this);
- }
+ /**
+ * Override setPartial to validate locale and theme
+ */
+ setPartial(partial: Partial): void {
+ const finalState = { ...this, ...partial };
- setPartial(partial: Partial) {
- const finalState = {
- ...this,
- ...partial,
- };
- if (!isValidateLocale(finalState.locale)) {
+ // Validate locale
+ if (partial.locale !== undefined && !isValidateLocale(finalState.locale)) {
+ console.warn(`Invalid locale "${finalState.locale}", falling back to "en"`);
finalState.locale = "en";
}
- if (!["default", "default-dark", "paper", "whitewall"].includes(finalState.theme)) {
- finalState.theme = "default";
+
+ // Validate theme - accept string and validate
+ if (partial.theme !== undefined) {
+ const themeStr = String(finalState.theme);
+ if (!isValidTheme(themeStr)) {
+ console.warn(`Invalid theme "${themeStr}", falling back to "default"`);
+ finalState.theme = "default";
+ } else {
+ finalState.theme = themeStr;
+ }
}
+
Object.assign(this, finalState);
}
}
+/**
+ * Workspace store instance
+ */
const workspaceStore = (() => {
- const state = new LocalState();
+ const base = createServerStore(new WorkspaceState(), {
+ name: "workspace",
+ enableDeduplication: true,
+ });
- const fetchWorkspaceSetting = async (settingKey: WorkspaceSetting_Key) => {
- const setting = await workspaceServiceClient.getWorkspaceSetting({ name: `${workspaceSettingNamePrefix}${settingKey}` });
- state.setPartial({
- settings: uniqBy([setting, ...state.settings], "name"),
- });
- };
+ const { state, executeRequest } = base;
- const upsertWorkspaceSetting = async (setting: WorkspaceSetting) => {
- await workspaceServiceClient.updateWorkspaceSetting({ setting });
- state.setPartial({
- settings: uniqBy([setting, ...state.settings], "name"),
- });
- };
+ /**
+ * Fetch a specific workspace setting by key
+ *
+ * @param settingKey - The setting key to fetch
+ */
+ const fetchWorkspaceSetting = async (settingKey: WorkspaceSetting_Key): Promise => {
+ const requestKey = createRequestKey("fetchWorkspaceSetting", { key: settingKey });
- const getWorkspaceSettingByKey = (settingKey: WorkspaceSetting_Key) => {
- return (
- state.settings.find((setting) => setting.name === `${workspaceSettingNamePrefix}${settingKey}`) || WorkspaceSetting.fromPartial({})
+ return executeRequest(
+ requestKey,
+ async () => {
+ const setting = await workspaceServiceClient.getWorkspaceSetting({
+ name: `${workspaceSettingNamePrefix}${settingKey}`,
+ });
+
+ // Merge into settings array, avoiding duplicates
+ state.setPartial({
+ settings: uniqBy([setting, ...state.settings], "name"),
+ });
+ },
+ "FETCH_WORKSPACE_SETTING_FAILED",
);
};
- const setTheme = async (theme: string) => {
+ /**
+ * Update or create a workspace setting
+ *
+ * @param setting - The setting to upsert
+ */
+ const upsertWorkspaceSetting = async (setting: WorkspaceSetting): Promise => {
+ return executeRequest(
+ "", // No deduplication for updates
+ async () => {
+ await workspaceServiceClient.updateWorkspaceSetting({ setting });
+
+ // Update local state
+ state.setPartial({
+ settings: uniqBy([setting, ...state.settings], "name"),
+ });
+ },
+ "UPDATE_WORKSPACE_SETTING_FAILED",
+ );
+ };
+
+ /**
+ * Get a workspace setting from cache by key
+ * Does not trigger a fetch
+ *
+ * @param settingKey - The setting key
+ * @returns The cached setting or an empty setting
+ */
+ const getWorkspaceSettingByKey = (settingKey: WorkspaceSetting_Key): WorkspaceSetting => {
+ const setting = state.settings.find((s) => s.name === `${workspaceSettingNamePrefix}${settingKey}`);
+ return setting || WorkspaceSetting.fromPartial({});
+ };
+
+ /**
+ * Set the workspace theme
+ * Updates both local state and persists to server
+ *
+ * @param theme - The theme to set
+ */
+ const setTheme = async (theme: string): Promise => {
+ // Validate theme
+ if (!isValidTheme(theme)) {
+ console.warn(`Invalid theme "${theme}", ignoring`);
+ return;
+ }
+
+ // Update local state immediately
state.setPartial({ theme });
- // Update the workspace setting - store theme in a custom field or handle differently
+ // Persist to server
const generalSetting = state.generalSetting;
const updatedGeneralSetting = WorkspaceSetting_GeneralSetting.fromPartial({
...generalSetting,
@@ -92,28 +205,65 @@ const workspaceStore = (() => {
);
};
+ /**
+ * Fetch workspace profile
+ */
+ const fetchWorkspaceProfile = async (): Promise => {
+ const requestKey = createRequestKey("fetchWorkspaceProfile");
+
+ return executeRequest(
+ requestKey,
+ async () => {
+ const profile = await workspaceServiceClient.getWorkspaceProfile({});
+ state.setPartial({ profile });
+ return profile;
+ },
+ "FETCH_WORKSPACE_PROFILE_FAILED",
+ );
+ };
+
return {
state,
fetchWorkspaceSetting,
+ fetchWorkspaceProfile,
upsertWorkspaceSetting,
getWorkspaceSettingByKey,
setTheme,
};
})();
-export const initialWorkspaceStore = async () => {
- const workspaceProfile = await workspaceServiceClient.getWorkspaceProfile({});
- // Prepare workspace settings.
- for (const key of [WorkspaceSetting_Key.GENERAL, WorkspaceSetting_Key.MEMO_RELATED]) {
- await workspaceStore.fetchWorkspaceSetting(key);
- }
+/**
+ * Initialize the workspace store
+ * Called once at app startup to load workspace profile and settings
+ *
+ * @throws Never - errors are logged but not thrown
+ */
+export const initialWorkspaceStore = async (): Promise => {
+ try {
+ // Fetch workspace profile
+ const workspaceProfile = await workspaceStore.fetchWorkspaceProfile();
- const workspaceGeneralSetting = workspaceStore.state.generalSetting;
- workspaceStore.state.setPartial({
- locale: workspaceGeneralSetting.customProfile?.locale,
- theme: "default",
- profile: workspaceProfile,
- });
+ // Fetch required settings
+ await Promise.all([
+ workspaceStore.fetchWorkspaceSetting(WorkspaceSetting_Key.GENERAL),
+ workspaceStore.fetchWorkspaceSetting(WorkspaceSetting_Key.MEMO_RELATED),
+ ]);
+
+ // Apply settings to state
+ const workspaceGeneralSetting = workspaceStore.state.generalSetting;
+ workspaceStore.state.setPartial({
+ locale: workspaceGeneralSetting.customProfile?.locale || "en",
+ theme: "default",
+ profile: workspaceProfile,
+ });
+ } catch (error) {
+ console.error("Failed to initialize workspace store:", error);
+ // Set default fallback values
+ workspaceStore.state.setPartial({
+ locale: "en",
+ theme: "default",
+ });
+ }
};
export default workspaceStore;