From ea12f0885b4dda7aabe8973f5af970f033b61d8d Mon Sep 17 00:00:00 2001 From: Vibe Kanban Date: Mon, 16 Feb 2026 16:43:58 +0000 Subject: [PATCH 1/5] Re-initialize Springboard engine on HMR for src/ changes When user code in src/ changes, the ModuleRunner re-imports the module but doesn't call start() again. This meant engine.initialize() never ran after HMR, so newly registered modules never executed their callbacks. Now handleHotUpdate detects src/ changes and schedules start() to run after the module reloads, ensuring the engine re-initializes and modules get their callbacks invoked. Co-Authored-By: Claude Opus 4.6 --- .../vite-plugin/src/plugins/dev.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/springboard/vite-plugin/src/plugins/dev.ts b/packages/springboard/vite-plugin/src/plugins/dev.ts index 9ae312d7..0019618f 100644 --- a/packages/springboard/vite-plugin/src/plugins/dev.ts +++ b/packages/springboard/vite-plugin/src/plugins/dev.ts @@ -238,6 +238,25 @@ export function springboardDev(options: NormalizedOptions): Plugin { logger.debug(`HMR update: ${file}`); } + // If user code changed, re-initialize the engine after module reload + // The ModuleRunner will automatically re-import the module, but we need + // to call start() again to re-initialize the Springboard engine + if (isNodePlatformActive && file.includes('/src/')) { + // Schedule start() to be called after the module reloads + // Use setImmediate to allow the module reload to complete first + setImmediate(async () => { + try { + if (nodeEntryModule && typeof nodeEntryModule.start === 'function') { + logger.info('[HMR] Re-initializing Springboard engine...'); + await nodeEntryModule.start(); + logger.info('[HMR] Engine re-initialized successfully'); + } + } catch (err) { + logger.error(`[HMR] Failed to re-initialize engine: ${err}`); + } + }); + } + // Let Vite handle HMR normally return undefined; }, From 64df4f78bf0190134b0d248e71d88fd264ce4706 Mon Sep 17 00:00:00 2001 From: Vibe Kanban Date: Mon, 16 Feb 2026 16:47:08 +0000 Subject: [PATCH 2/5] Improve HMR user code detection to be cross-platform Replace fragile /src/ string check with robust path-based logic: - Check file starts with project root - Exclude node_modules, .springboard, and dist directories - Use path.sep for cross-platform compatibility Co-Authored-By: Claude Opus 4.6 --- packages/springboard/vite-plugin/src/plugins/dev.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/springboard/vite-plugin/src/plugins/dev.ts b/packages/springboard/vite-plugin/src/plugins/dev.ts index 0019618f..5a32718a 100644 --- a/packages/springboard/vite-plugin/src/plugins/dev.ts +++ b/packages/springboard/vite-plugin/src/plugins/dev.ts @@ -241,7 +241,16 @@ export function springboardDev(options: NormalizedOptions): Plugin { // If user code changed, re-initialize the engine after module reload // The ModuleRunner will automatically re-import the module, but we need // to call start() again to re-initialize the Springboard engine - if (isNodePlatformActive && file.includes('/src/')) { + + // Check if the changed file is within the project root (excludes node_modules, etc.) + // and is not a generated file (excludes .springboard/, dist/, etc.) + const isUserCode = isNodePlatformActive && + file.startsWith(options.root) && + !file.includes(path.sep + 'node_modules' + path.sep) && + !file.includes(path.sep + '.springboard' + path.sep) && + !file.includes(path.sep + 'dist' + path.sep); + + if (isUserCode) { // Schedule start() to be called after the module reloads // Use setImmediate to allow the module reload to complete first setImmediate(async () => { From 4e0ed63a3854bb9cad78a7df06456373006f7f29 Mon Sep 17 00:00:00 2001 From: Vibe Kanban Date: Tue, 24 Feb 2026 02:49:41 +0000 Subject: [PATCH 3/5] Add createLocalSessionState API for sessionStorage-backed state Implement a new state management API that uses sessionStorage for tab-specific, temporary state that clears when the browser tab closes. Changes: - Add BrowserSessionKVStoreService wrapping sessionStorage - Add session storage to CoreDependencies with optional support - Add sessionSharedStateService to ModuleDependencies - Implement createLocalSessionState in StatesAPI with fallback - Initialize session storage in browser and Tauri entrypoints - Update state management documentation with new state type The implementation includes graceful fallback to localStorage when sessionStorage is not available, ensuring backward compatibility. Co-Authored-By: Claude Sonnet 4.5 --- .../content/springboard-state-management.md | 45 ++++++++++++++++++- .../springboard/src/core/engine/engine.tsx | 12 +++++ .../springboard/src/core/engine/module_api.ts | 36 +++++++++++++++ .../src/core/types/module_types.ts | 2 + .../browser/entrypoints/offline_entrypoint.ts | 3 ++ .../browser/entrypoints/online_entrypoint.ts | 3 ++ .../src/platforms/browser/index.ts | 1 + .../browser_session_kvstore_service.ts | 37 +++++++++++++++ .../entrypoints/platform_tauri_browser.tsx | 3 ++ .../src/templates/web-entry.template.ts | 4 +- 10 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 packages/springboard/src/platforms/browser/services/browser_session_kvstore_service.ts diff --git a/packages/springboard/cli/src/docs/content/springboard-state-management.md b/packages/springboard/cli/src/docs/content/springboard-state-management.md index 0f1200cd..bc354c3e 100644 --- a/packages/springboard/cli/src/docs/content/springboard-state-management.md +++ b/packages/springboard/cli/src/docs/content/springboard-state-management.md @@ -1,6 +1,6 @@ # State Management -Springboard provides three types of state, each with different persistence and sync behavior. +Springboard provides four types of state, each with different persistence and sync behavior. ## State Types Comparison @@ -9,6 +9,7 @@ Springboard provides three types of state, each with different persistence and s | SharedState | In-memory (server cache) | Real-time cross-device | Temporary shared data, UI sync | | PersistentState | Database | Cross-device on load | User data, settings, saved state | | UserAgentState | localStorage | None (device-local) | UI preferences, local settings | +| LocalSessionState | sessionStorage | None (tab-local) | Tab-specific temporary state | ## createSharedState @@ -92,6 +93,39 @@ localPrefs.setStateImmer(draft => { }); ``` +## createLocalSessionState + +```typescript +const formData = await moduleAPI.statesAPI.createLocalSessionState('formData', { + step: 1, + values: {} +}); +``` + +**Behavior:** +- Stored in sessionStorage (browser only) +- Never synced to server or other devices +- Tab/window-specific +- Cleared when tab/window is closed +- Use for: multi-step forms, temporary drafts, tab-specific UI state + +**Example:** +```typescript +const wizardState = await moduleAPI.statesAPI.createLocalSessionState('wizard', { + currentStep: 1, + formData: {} as Record, + visited: [] as number[] +}); + +// Only affects this browser tab +wizardState.setStateImmer(draft => { + draft.currentStep = 2; + draft.visited.push(1); +}); + +// State is automatically cleared when tab closes +``` + ## StateSupervisor API All state types return a `StateSupervisor`: @@ -140,4 +174,11 @@ moduleAPI.onDestroy(() => subscription.unsubscribe()); **Use UserAgentState when:** - Data is device-specific - No need to sync across devices -- Examples: UI state, local preferences, cached data +- Should persist across sessions +- Examples: UI preferences, local settings, cached data + +**Use LocalSessionState when:** +- Data is tab/window-specific +- Should NOT persist when tab closes +- No need to sync across devices or tabs +- Examples: multi-step form progress, temporary drafts, wizard state, tab-specific UI diff --git a/packages/springboard/src/core/engine/engine.tsx b/packages/springboard/src/core/engine/engine.tsx index 7755f46d..1f54385d 100644 --- a/packages/springboard/src/core/engine/engine.tsx +++ b/packages/springboard/src/core/engine/engine.tsx @@ -61,6 +61,7 @@ export class Springboard { private remoteSharedStateService!: SharedStateService; private localSharedStateService!: SharedStateService; + private sessionSharedStateService?: SharedStateService; initialize = async () => { const initStartTime = now(); @@ -97,6 +98,16 @@ export class Springboard { }); await this.localSharedStateService.initialize(); + if (this.coreDeps.storage.session) { + this.sessionSharedStateService = new SharedStateService({ + rpc: this.coreDeps.rpc.local, + kv: this.coreDeps.storage.session, + log: this.coreDeps.log, + isMaestro: this.coreDeps.isMaestro, + }); + await this.sessionSharedStateService.initialize(); + } + this.moduleRegistry = new ModuleRegistry(); const registeredClassModuleCallbacks = (springboard.registerClassModule as unknown as {calls: CapturedRegisterClassModuleCalls[]}).calls || []; @@ -182,6 +193,7 @@ export class Springboard { services: { remoteSharedStateService: this.remoteSharedStateService, localSharedStateService: this.localSharedStateService, + sessionSharedStateService: this.sessionSharedStateService, }, }; }; diff --git a/packages/springboard/src/core/engine/module_api.ts b/packages/springboard/src/core/engine/module_api.ts index 92f75902..bbeb8877 100644 --- a/packages/springboard/src/core/engine/module_api.ts +++ b/packages/springboard/src/core/engine/module_api.ts @@ -306,4 +306,40 @@ export class StatesAPI { return supervisor; }; + + /** + * Create a piece of state to be saved in sessionStorage. This state persists only for the current browser tab/window session. + * The state is cleared when the tab/window is closed, making it useful for temporary, tab-specific state. + */ + public createLocalSessionState = async (stateName: string, initialValue: State): Promise> => { + const fullKey = `${this.prefix}|state.session|${stateName}`; + + if (this.coreDeps.storage.session && this.modDeps.services.sessionSharedStateService) { + const cachedValue = this.modDeps.services.sessionSharedStateService.getCachedValue(fullKey) as State | undefined; + if (cachedValue !== undefined) { + initialValue = cachedValue; + } else { + const storedValue = await this.coreDeps.storage.session.get(fullKey); + if (storedValue !== null && storedValue !== undefined) { + initialValue = storedValue; + } + } + + const supervisor = new SharedStateSupervisor(fullKey, initialValue, this.modDeps.services.sessionSharedStateService); + + const sub = supervisor.subjectForKVStorePublish.subscribe(async value => { + await this.coreDeps.storage.session!.set(fullKey, value); + }); + this.onDestroy(sub.unsubscribe); + + return supervisor; + } + + // Fallback to UserAgentStateSupervisor if session storage not available + const supervisor = new UserAgentStateSupervisor(fullKey, initialValue, this.coreDeps.storage.userAgent); + this.onDestroy(() => { + // No-op cleanup since UserAgentStateSupervisor handles its own storage + }); + return supervisor; + }; } diff --git a/packages/springboard/src/core/types/module_types.ts b/packages/springboard/src/core/types/module_types.ts index 1c131678..3b6f83b3 100644 --- a/packages/springboard/src/core/types/module_types.ts +++ b/packages/springboard/src/core/types/module_types.ts @@ -14,6 +14,7 @@ export type CoreDependencies = { storage: { remote: KVStore; userAgent: KVStore; + session?: KVStore; }; rpc: { remote: Rpc; @@ -60,5 +61,6 @@ export type ModuleDependencies = { services: { remoteSharedStateService: SharedStateService; localSharedStateService : SharedStateService; + sessionSharedStateService?: SharedStateService; }; } diff --git a/packages/springboard/src/platforms/browser/entrypoints/offline_entrypoint.ts b/packages/springboard/src/platforms/browser/entrypoints/offline_entrypoint.ts index c2f3433b..b698215c 100644 --- a/packages/springboard/src/platforms/browser/entrypoints/offline_entrypoint.ts +++ b/packages/springboard/src/platforms/browser/entrypoints/offline_entrypoint.ts @@ -2,6 +2,7 @@ import {MockRpcService} from '../../../core/test/mock_core_dependencies.js'; import React from 'react'; import {BrowserKVStoreService} from '../services/browser_kvstore_service.js'; +import {BrowserSessionKVStoreService} from '../services/browser_session_kvstore_service.js'; import {startAndRenderBrowserApp} from './react_entrypoint.js'; (globalThis as {useHashRouter?: boolean}).useHashRouter = true; @@ -11,6 +12,7 @@ setTimeout(() => { const rpc = new MockRpcService(); const remoteKvStore = new BrowserKVStoreService(localStorage); const userAgentKVStore = new BrowserKVStoreService(localStorage); + const sessionKVStore = new BrowserSessionKVStoreService(sessionStorage); startAndRenderBrowserApp({ rpc: { @@ -21,6 +23,7 @@ setTimeout(() => { storage: { userAgent: userAgentKVStore, remote: remoteKvStore, + session: sessionKVStore, }, }); }); diff --git a/packages/springboard/src/platforms/browser/entrypoints/online_entrypoint.ts b/packages/springboard/src/platforms/browser/entrypoints/online_entrypoint.ts index 8da8cee7..81eebe99 100644 --- a/packages/springboard/src/platforms/browser/entrypoints/online_entrypoint.ts +++ b/packages/springboard/src/platforms/browser/entrypoints/online_entrypoint.ts @@ -1,5 +1,6 @@ import {BrowserJsonRpcClientAndServer} from '../services/browser_json_rpc.js'; import {BrowserKVStoreService} from '../services/browser_kvstore_service.js'; +import {BrowserSessionKVStoreService} from '../services/browser_session_kvstore_service.js'; import {HttpKvStoreClient as HttpKVStoreService} from '../../../core/services/http_kv_store_client.js'; import {startAndRenderBrowserApp} from './react_entrypoint.js'; @@ -20,6 +21,7 @@ setTimeout(() => { const rpc = new BrowserJsonRpcClientAndServer(`${WS_HOST}/ws`); const remoteKvStore = new HttpKVStoreService(DATA_HOST); const userAgentKVStore = new BrowserKVStoreService(localStorage); + const sessionKVStore = new BrowserSessionKVStoreService(sessionStorage); startAndRenderBrowserApp({ rpc: { @@ -29,6 +31,7 @@ setTimeout(() => { storage: { userAgent: userAgentKVStore, remote: remoteKvStore, + session: sessionKVStore, }, dev: { reloadCss, diff --git a/packages/springboard/src/platforms/browser/index.ts b/packages/springboard/src/platforms/browser/index.ts index 40ab7fd9..f02100bf 100644 --- a/packages/springboard/src/platforms/browser/index.ts +++ b/packages/springboard/src/platforms/browser/index.ts @@ -6,6 +6,7 @@ // Export browser services export { BrowserJsonRpcClientAndServer } from './services/browser_json_rpc.js'; export { BrowserKVStoreService } from './services/browser_kvstore_service.js'; +export { BrowserSessionKVStoreService } from './services/browser_session_kvstore_service.js'; // Export browser entrypoints export { startAndRenderBrowserApp } from './entrypoints/react_entrypoint.js'; diff --git a/packages/springboard/src/platforms/browser/services/browser_session_kvstore_service.ts b/packages/springboard/src/platforms/browser/services/browser_session_kvstore_service.ts new file mode 100644 index 00000000..043f9c9f --- /dev/null +++ b/packages/springboard/src/platforms/browser/services/browser_session_kvstore_service.ts @@ -0,0 +1,37 @@ +import {KVStore} from '../../../core/types/module_types.js'; + +export class BrowserSessionKVStoreService implements KVStore { + constructor(private ss: Window['sessionStorage']) {} + + getAll = async (): Promise | null> => { + const allKeys = Object.keys(this.ss); + + const entriesAsRecord: Record = {}; + for (const key of allKeys) { + const value = this.ss.getItem(key); + if (value) { + try { + entriesAsRecord[key] = JSON.parse(value); + } catch (e) { + // eslint-disable-line no-empty + } + } + } + + return entriesAsRecord; + }; + + get = async (key: string): Promise => { + const s = this.ss.getItem(key); + if (!s) { + return null; + } + + return JSON.parse(s) as T; + }; + + set = async (key: string, value: T): Promise => { + const s = JSON.stringify(value); + this.ss.setItem(key, s); + }; +} diff --git a/packages/springboard/src/platforms/tauri/entrypoints/platform_tauri_browser.tsx b/packages/springboard/src/platforms/tauri/entrypoints/platform_tauri_browser.tsx index 22aac1c2..6d1d7fdf 100644 --- a/packages/springboard/src/platforms/tauri/entrypoints/platform_tauri_browser.tsx +++ b/packages/springboard/src/platforms/tauri/entrypoints/platform_tauri_browser.tsx @@ -11,6 +11,7 @@ import {HttpKvStoreClient as HttpKVStoreService} from '../../../core/services/ht import {Main} from '../../browser/entrypoints/main.js'; // import {Main} from './main.js'; import {BrowserKVStoreService} from '../../browser/services/browser_kvstore_service.js'; +import {BrowserSessionKVStoreService} from '../../browser/services/browser_session_kvstore_service.js'; import {BrowserJsonRpcClientAndServer} from '../../browser/services/browser_json_rpc.js'; import {Springboard} from '../../../core/engine/engine.js'; import {ExtraModuleDependencies} from '../../../core/module_registry/module_registry.js'; @@ -35,6 +36,7 @@ export const startAndRenderBrowserApp = async (): Promise => { // const kvStore = new BrowserKVStoreService(localStorage); const userAgentKVStore = new BrowserKVStoreService(localStorage); + const sessionKVStore = new BrowserSessionKVStoreService(sessionStorage); // const kvStore = mockDeps.storage.remote; // const userAgentKVStore = mockDeps.storage.userAgent; @@ -48,6 +50,7 @@ export const startAndRenderBrowserApp = async (): Promise => { storage: { remote: kvStore, userAgent: userAgentKVStore, + session: sessionKVStore, }, rpc: { remote: rpc, diff --git a/packages/springboard/vite-plugin/src/templates/web-entry.template.ts b/packages/springboard/vite-plugin/src/templates/web-entry.template.ts index 8b2afb35..514d9740 100644 --- a/packages/springboard/vite-plugin/src/templates/web-entry.template.ts +++ b/packages/springboard/vite-plugin/src/templates/web-entry.template.ts @@ -2,6 +2,7 @@ import { startAndRenderBrowserApp } from 'springboard/platforms/browser/entrypoi import { BrowserJsonRpcClientAndServer } from 'springboard/platforms/browser/services/browser_json_rpc'; import { HttpKvStoreClient } from 'springboard/core/services/http_kv_store_client'; import { BrowserKVStoreService } from 'springboard/platforms/browser/services/browser_kvstore_service'; +import { BrowserSessionKVStoreService } from 'springboard/platforms/browser/services/browser_session_kvstore_service'; import '__USER_ENTRY__'; // Determine protocol based on current page @@ -16,9 +17,10 @@ const DATA_HOST = import.meta.env.VITE_DATA_HOST || `${httpProtocol}://${locatio const rpc = new BrowserJsonRpcClientAndServer(`${WS_HOST}/ws`); const remoteKvStore = new HttpKvStoreClient(DATA_HOST); const userAgentKvStore = new BrowserKVStoreService(localStorage); +const sessionKvStore = new BrowserSessionKVStoreService(sessionStorage); startAndRenderBrowserApp({ rpc: { remote: rpc, local: undefined }, - storage: { userAgent: userAgentKvStore, remote: remoteKvStore }, + storage: { userAgent: userAgentKvStore, remote: remoteKvStore, session: sessionKvStore }, dev: { reloadCss: false, reloadJs: false }, }); From c347ab708d1f584f95139ae3908791a16002d4a4 Mon Sep 17 00:00:00 2001 From: Vibe Kanban Date: Tue, 24 Feb 2026 02:50:43 +0000 Subject: [PATCH 4/5] Use process.env.PORT in node server template if defined Allow the node server to use PORT environment variable at runtime instead of having the port locked in at compile time. Falls back to the configured port (__PORT__) if PORT is not set. This enables more flexible deployment scenarios where the port needs to be configured at runtime rather than build time. Co-Authored-By: Claude Sonnet 4.5 --- .../vite-plugin/src/templates/node-entry.template.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/springboard/vite-plugin/src/templates/node-entry.template.ts b/packages/springboard/vite-plugin/src/templates/node-entry.template.ts index 14a836f0..f29507bb 100644 --- a/packages/springboard/vite-plugin/src/templates/node-entry.template.ts +++ b/packages/springboard/vite-plugin/src/templates/node-entry.template.ts @@ -55,8 +55,8 @@ export async function start() { hooks: createWebSocketHooks(useWebSocketsForRpc), }); - // Use configured port (ignores process.env.PORT to avoid conflicts) - const port = __PORT__; + // Use PORT environment variable if defined, otherwise use configured port + const port = process.env.PORT ? parseInt(process.env.PORT, 10) : __PORT__; // Start the HTTP server server = serve({ From 7b8f4236a77d92c70cb536c62c53c814ee328713 Mon Sep 17 00:00:00 2001 From: Vibe Kanban Date: Wed, 25 Feb 2026 23:25:05 +0000 Subject: [PATCH 5/5] Add kv-2.db to .gitignore Ignore additional KV database file to prevent local database files from being committed to the repository. Co-Authored-By: Claude Sonnet 4.5 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index c0dd68fd..ea878303 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ dist site typedoc_docs kv.db +kv-2.db .env kv_data.json