The USE system includes a sophisticated snapshot system for capturing and replaying state changes with flexible trigger logic, dual storage backends, and powerful time-travel capabilities. Snapshots enable undo/redo systems, session restoration, automated saving, and debugging workflows with precise control over when and what state gets captured.
This guide covers:
onSnapshot and onReplay callbacks for reliable coordinationThe snapshot system is fully asynchronous from top to bottom, ensuring reliable operation without blocking the main thread. Understanding this architecture is critical for proper usage, especially in tests and coordination scenarios.
Complete Async Flow:
State Change
↓
Observer Triggered (microtask)
↓
Snapshot Queued
↓
Snapshot Captured (async)
↓
Timeline Storage (async, queued per snapshot name)
↓
Custom callback() executed (can be async)
↓
onSnapshot() notification fired (completion signal)
Key Async Behaviors:
Initial Snapshots: When a snapshot is registered with wildcard ('*') or matching triggers, an initial snapshot is captured immediately if current state matches conditions
const state = Doh.use_state({ document: { content: 'Hello' } });
// This will capture an INITIAL snapshot because content exists and matches '*'
await Doh.use_snapshot('doc', {
triggers: { 'document.content': '*' }, // Wildcard matches existing 'Hello'
states: ['document.content'],
in_timeline: true
});
// Timeline now has 1 entry (initial snapshot)
state.document.content = 'World'; // Creates 2nd snapshot
await Doh.microtask();
// Timeline now has 2 entries (initial + this change)
Microtask Batching: Multiple rapid changes to the same path create only one snapshot due to USE system's microtask batching
state.document.content = 'A';
state.document.content = 'B';
state.document.content = 'C';
// Result: ONE snapshot with final value 'C'
// To create separate snapshots:
state.document.content = 'A';
await Doh.microtask(); // Let it process
state.document.content = 'B';
await Doh.microtask(); // Let it process
state.document.content = 'C';
// Result: THREE separate snapshots
Queue-Based Timeline Operations: Each snapshot name has its own operation queue, ensuring:
onSnapshot fires only after ALL async operations complete// Rapid changes are queued and processed in order
state.doc.content = 'Change 1'; // Queued
state.doc.content = 'Change 2'; // Queued
state.doc.content = 'Change 3'; // Queued
// Meanwhile, different snapshot can operate in parallel
state.user.name = 'Alice'; // Different queue, processes concurrently
Completion Notifications: The onSnapshot and onReplay callbacks signal when ALL async operations finish
let completed = false;
await Doh.use_snapshot('test', {
triggers: { 'data': '*' },
states: ['data'],
in_timeline: true,
callback: function(snap) {
// Fires DURING capture (before storage completes)
console.log('Captured');
},
onSnapshot: function(snap) {
// Fires AFTER storage completes (including IndexedDB writes)
completed = true;
}
});
state.data = 'value';
await Doh.microtask();
// Now safe to wait for completion
while (!completed) await Doh.microtask();
// Timeline is guaranteed to be updated
const info = await Doh.use_timeline_info('test');
console.log(info.length); // Accurate count
Test Pattern: Await Completion Instead of Arbitrary Timeouts
// ❌ BAD: Random waits
state.document.content = 'Hello';
await new Promise(resolve => setTimeout(resolve, 100)); // Hope it's enough?
// ✅ GOOD: Use completion notifications
let snapshotDone = false;
await Doh.use_snapshot('doc', {
triggers: { 'document.content': '*' },
states: ['document.content'],
onSnapshot: () => { snapshotDone = true; }
});
state.document.content = 'Hello';
while (!snapshotDone) await Doh.microtask();
// Guaranteed complete
Every snapshot definition includes:
name: Unique identifier for the snapshot collectiontriggers: Conditions that determine when snapshots are capturedstates: Which state paths to capture in the snapshotawait Doh.use_snapshot('snapshot_name', {
triggers: { /* trigger conditions */ },
states: [ /* state paths to capture */ ],
// Storage options (choose one or both)
as_history: true, // Browser history integration
in_timeline: 50, // IndexedDB timeline with rotation
// Advanced options
save_as: 'shared_name', // Optional: Save to shared timeline(s)
trigger_mode_or: false, // AND logic (default) vs OR logic
callback: async function(data) {}, // Custom processing (async supported)
// Completion notifications (async supported)
onSnapshot: async function(data) {}, // Called when snapshot capture completes
onReplay: async function(data) {} // Called when replay operation completes
});
| Backend | Persistence | Capacity | Navigation | Performance | Use Case |
|---|---|---|---|---|---|
| Browser History | Session only | ~50 entries | Browser back/forward | Synchronous | Navigation states |
| IndexedDB Timeline | Cross-session | Unlimited | Built-in undo/redo | Asynchronous | Document editing, complex workflows |
Critical Distinction: Snapshot triggers differentiate between watching for specific values versus path bubbling behavior:
// String format - watches path and all its properties via bubbling
await Doh.use_snapshot('string_triggers', {
triggers: 'user.profile', // Triggers on user.profile.name, user.profile.email, etc.
states: ['user']
});
// Array format - multiple paths with bubbling
await Doh.use_snapshot('array_triggers', {
triggers: ['user.profile', 'settings.theme'], // Both paths use bubbling
states: ['user', 'settings']
});
// Mixed: specific values and bubbling behavior
await Doh.use_snapshot('object_triggers', {
triggers: {
'user.active': true, // ONLY when user.active becomes exactly true
'user.profile': '*', // ANY change to user.profile.* (same as string format)
'page.path': '/dashboard', // ONLY when path becomes '/dashboard'
'document.content': '*', // ANY change to document.content (bubbling)
'game.health': 0 // ONLY when health reaches exactly 0
},
states: ['user', 'page', 'document', 'game']
});
Key Point: '*' in object format provides the same path bubbling behavior as string/array format.
String Trigger - Single path with bubbling:
await Doh.use_snapshot('simple_page', {
triggers: 'currentPage', // Equivalent to { 'currentPage': '*' }
states: ['currentPage', 'pageData']
});
Array Triggers - Multiple paths with bubbling:
await Doh.use_snapshot('form_fields', {
triggers: ['form.name', 'form.email', 'form.message'], // All treated as '*'
states: 'form'
});
Object Triggers - Precise control:
await Doh.use_snapshot('mixed_conditions', {
triggers: {
'user.loggedIn': true, // Specific value: must be true
'document.modified': '*', // Any change to modified flag
'session.timeout': 0, // Specific value: must reach 0
'ui.theme': '*' // Any theme change
},
states: ['user', 'document', 'session', 'ui']
});
AND Logic (Default) - ALL triggers must be satisfied:
// Snapshot only triggers when BOTH conditions are met
Doh.use_snapshot('and_logic', {
triggers: {
'user.loggedIn': true, // First condition
'user.profile': '*' // Second condition (any profile change)
},
states: ['user.profile', 'user.session']
});
// Sequence required:
state.user.loggedIn = true; // First trigger met, no snapshot yet
state.user.profile.name = 'Alice'; // Both triggers met - snapshot captured!
OR Logic - ANY trigger will execute snapshot:
// Snapshot triggers on any form field change
Doh.use_snapshot('or_logic', {
triggers: ['form.name', 'form.email', 'form.message'],
trigger_mode_or: true, // Enable OR logic
states: 'form'
});
// Any of these will trigger a snapshot:
state.form.name = 'John'; // Triggers snapshot
state.form.email = 'john@test'; // Triggers snapshot
state.form.message = 'Hello'; // Triggers snapshot
Custom Logic Functions - Complete control:
// Advanced business logic: save if form is valid OR user is premium
Doh.use_snapshot('custom_logic', {
triggers: { 'form.errors': 0, 'form.submitted': true, 'user.premium': true },
trigger_mode_or: function(triggers) {
// triggers object contains state for each trigger path:
// { 'form.errors': { met: boolean, value: any, expectedValue: any, timestamp: number }, ... }
// Business rule: snapshot if (no errors AND submitted) OR (user is premium)
const validSubmission = triggers['form.errors'].met && // errors === 0
triggers['form.submitted'].met; // submitted === true
const premiumUser = triggers['user.premium'].met; // premium === true
return validSubmission || premiumUser;
},
states: ['form.data', 'user.account']
});
// Time-based custom logic
Doh.use_snapshot('time_based', {
triggers: { 'document.modified': true, 'session.lastSave': '*' },
trigger_mode_or: function(triggers) {
const modified = triggers['document.modified'];
const lastSave = triggers['session.lastSave'];
// Only save if document was modified AND enough time has passed
if (modified.met && lastSave.met) {
const timeSinceLastSave = Date.now() - lastSave.timestamp;
return timeSinceLastSave > 5 * 60 * 1000; // 5 minutes
}
return false;
},
states: 'document'
});
Trigger State Properties: Each trigger in custom functions receives:
met: Boolean - whether the trigger condition was satisfiedvalue: The current state value at the trigger pathexpectedValue: The value the trigger was watching for (or true for wildcard)timestamp: When this trigger condition was last met (Date.now())Replay triggers define conditions that automatically restore (replay) the most recent snapshot when met. They work independently from save triggers, enabling powerful auto-recovery and state synchronization patterns.
// Auto-restore on app crash detection
Doh.use_snapshot('crash_recovery', {
// Save triggers - when to capture state
triggers: { 'document.saved': true },
states: ['document.content', 'document.metadata'],
// Replay triggers - when to auto-restore
replay_triggers: { 'app.crashed': true },
in_timeline: true
});
// When app.crashed becomes true, automatically replays the last captured snapshot
AND Logic (Default) - All replay triggers must be met:
Doh.use_snapshot('secure_restore', {
triggers: 'document.content',
states: 'document.content',
replay_triggers: {
'user.authenticated': true,
'app.ready': true
},
// Both conditions must be true to auto-replay
replay_trigger_mode_or: false // Default AND logic
});
OR Logic - Any replay trigger activates restoration:
Doh.use_snapshot('flexible_restore', {
triggers: 'form.data',
states: ['form.data', 'form.validation'],
replay_triggers: {
'user.clickedUndo': true,
'app.errorRecovered': true,
'session.restored': true
},
replay_trigger_mode_or: true // Any trigger causes replay
});
Custom Function Logic - Complex business rules:
Doh.use_snapshot('smart_restore', {
triggers: 'workspace.state',
states: ['workspace.tabs', 'workspace.layout'],
replay_triggers: {
'session.type': 'restored',
'user.premium': true,
'workspace.lastSaved': '*'
},
replay_trigger_mode_or: function(triggers) {
// Only auto-restore for premium users during session restoration
// OR if enough time has passed since last save
const sessionRestore = triggers['session.type'].met;
const isPremium = triggers['user.premium'].met;
const recentSave = triggers['workspace.lastSaved'];
if (sessionRestore && isPremium) {
return true;
}
// Auto-restore if last save was over 10 minutes ago
if (recentSave.met) {
const timeSince = Date.now() - recentSave.timestamp;
return timeSince > 10 * 60 * 1000;
}
return false;
}
});
Error Recovery:
Doh.use_snapshot('error_recovery', {
triggers: { 'form.valid': true },
states: 'form',
replay_triggers: { 'form.error': false }, // Restore when error clears
replay_trigger_mode_or: true
});
Navigation Restoration:
Doh.use_snapshot('nav_restore', {
triggers: 'router.currentRoute',
states: ['router.currentRoute', 'router.params'],
replay_triggers: { 'router.restoreRequested': true },
as_history: true
});
Session Recovery:
Doh.use_snapshot('session_state', {
triggers: ['user.preferences', 'ui.layout'],
states: ['user.preferences', 'ui.layout', 'workspace.openFiles'],
replay_triggers: { 'session.type': 'restored' },
in_timeline: true
});
Time-based Auto-save with Recovery:
Doh.use_snapshot('auto_recovery', {
triggers: { 'document.content': '*' },
states: 'document',
replay_triggers: { 'app.restarted': true },
callback: function(data) {
console.log('Document auto-saved:', data.timestamp);
}
});
When replay_triggers match but no snapshot has been captured yet, you can specify replay_defaults to apply default values instead of doing nothing. This is useful for tab-specific state management where tabs that never had state changes need a "clean slate."
Problem Solved: Without replay_defaults, if Tab A sets state.filters = {search: 'batman'} and Tab B never set filters, switching to Tab B would leave Tab A's filters leaking into Tab B's view.
await Doh.use_snapshot('vendor_admin_tab_state', {
triggers: { 'admintab': 'vendor', 'filters': '*' },
states: ['admintab', 'view', 'itemid', 'filters', 'page', 'pageSize'],
replay_triggers: { 'admintab': 'vendor' },
// Applied when replay_triggers match but no snapshot exists
replay_defaults: {
'filters': {},
'view': 'grid',
'itemid': null,
'page': 1,
'pageSize': null
}
});
Behavior:
replay_triggers condition is metreplay_defaults to stateThe replay_defaults object uses state paths as keys (same as states), with the default values to apply. The onReplay callback still fires, with snapshotData._isDefaults = true to indicate defaults were applied rather than a real snapshot.
replay_defaults is specified[USE SNAPSHOT] Auto-replaying... messagesString States - Single state path:
Doh.use_snapshot('theme_changes', {
triggers: 'ui.theme',
states: 'ui.theme' // Capture just the theme
});
Array States - Multiple state paths:
Doh.use_snapshot('search_activity', {
triggers: 'search.query',
states: ['search.query', 'search.filters', 'search.results', 'search.timestamp']
});
Object States - States with defaults:
Doh.use_snapshot('user_activity', {
triggers: 'activity.type',
states: {
'user.id': null, // Default if undefined
'activity.type': 'unknown', // Default activity type
'activity.timestamp': Date.now, // Function called for default
'activity.duration': 0, // Static default
'session.context': {} // Default object
}
});
// When triggered, snapshot contains the current values or defaults for missing paths
State paths use dot notation and support deep nesting:
const state = Doh.use_state({
user: {
profile: {
name: 'John',
settings: { theme: 'light', notifications: true }
},
activity: {
lastLogin: Date.now(),
pageViews: 42
}
}
});
Doh.use_snapshot('user_session', {
triggers: 'user.profile.name',
states: [
'user.profile', // Entire profile object
'user.profile.name', // Just the name string
'user.profile.settings.theme', // Deep nested value
'user.activity.lastLogin' // Activity timestamp
]
});
// Snapshot data structure:
// {
// name: 'user_session',
// timestamp: 1234567890,
// triggerCount: 1,
// states: {
// 'user.profile': { name: 'John', settings: { theme: 'light', notifications: true } },
// 'user.profile.name': 'John',
// 'user.profile.settings.theme': 'light',
// 'user.activity.lastLogin': 1234567890
// }
// }
Store snapshots in browser history for navigation-based state restoration. When as_history: true is used, Doh automatically installs a global popstate handler that replays snapshots when the user navigates with browser back/forward buttons.
Doh.use_snapshot('navigation_state', {
triggers: 'router.currentRoute',
states: ['router.currentRoute', 'router.params', 'router.query'],
as_history: true // Add to browser history
});
// That's it! No manual popstate handler needed.
// Each route change adds a history entry with the snapshot
// Browser back/forward buttons automatically restore state
Automatic Popstate Handling:
as_history: true, Doh installs a global popstate listener (once)as_history: true and navigation worksHistory State Structure:
// Browser history.state contains:
{
doh_snapshot: true, // Boolean flag indicating snapshot data exists
snapshot: {
name: 'navigation_state',
timestamp: 1234567890,
triggerCount: 1,
states: { /* captured state data */ }
}
}
// Manual access (rarely needed since popstate is automatic):
if (history.state?.doh_snapshot && history.state?.snapshot) {
await Doh.use_replay_snapshot(history.state.snapshot);
}
The snapshot system provides comprehensive URL query parameter management through two complementary features:
Add save_url_query: true to snapshot definitions to capture and restore URL query parameters along with state:
// Define snapshot that saves URL query parameters
Doh.use_snapshot('navigation_state', {
triggers: 'router.currentRoute', // Watches router.currentRoute and all its properties (bubbling)
states: ['router.currentRoute', 'router.params', 'ui.filters'],
save_url_query: true, // Capture URL query parameters with snapshot
clear_url_query: true, // Default behavior for replay (optional)
in_timeline: true
});
// When triggered, captures both state and current URL query like:
// ?page=2&filter=active&search=user
Important: The use_replay_snapshot function is async and returns a Promise that must be awaited. This ensures proper cascade prevention and queue ordering during replay operations.
The use_replay_snapshot function and related timeline functions support an optional options parameter for controlling replay behavior:
// Basic replay (uses snapshot's default clear_url_query setting)
await Doh.use_replay_snapshot(snapshotData);
// Explicitly control URL clearing during replay
await Doh.use_replay_snapshot(snapshotData, {
clear_url_query: true // Override snapshot's default setting
});
// Timeline functions also support the same options
await Doh.use_timeline_goto('snapshot_name', 0, { clear_url_query: true });
await Doh.use_timeline_undo('snapshot_name', { clear_url_query: true });
await Doh.use_timeline_redo('snapshot_name', { clear_url_query: true });
Snapshot Definition Options:
save_url_query (boolean, default: false) - Captures current URL query parameters as part of the snapshot dataclear_url_query (boolean, default: undefined) - Default behavior for URL clearing during replayReplay Options:
clear_url_query (boolean, optional) - Controls URL clearing during replay:true: Clear URL and restore saved query (if available)false: Keep current URL, don't restore saved queryundefined: Use snapshot's default settingSmart URL Handling: When a snapshot has saved URL query parameters:
clear_url_query defaults to true unless explicitly set to falseUse Cases:
The timeline system provides the most robust storage with dedicated IndexedDB databases, automatic rotation, and built-in navigation:
// Basic timeline storage
Doh.use_snapshot('document_history', {
triggers: { 'document.content': '*' },
states: ['document.content', 'document.title', 'document.metadata'],
in_timeline: true // Enable IndexedDB timeline
});
// Timeline with automatic rotation
Doh.use_snapshot('user_actions', {
triggers: { 'user.action': '*' },
states: ['user.profile', 'user.activity', 'user.context'],
in_timeline: 50 // Keep only last 50 entries
});
// Local-only undo for collaborative apps (skip remote changes)
// See "Collaborative Undo/Redo Pattern" section below for complete implementation
Doh.use_snapshot('local_undo', {
triggers: { 'document.content': '*' },
states: ['document.content'],
skip_remote_changes: true, // Only capture LOCAL changes
in_timeline: 50
});
The skip_remote_changes Option:
When building collaborative applications with use_remote, changes can come from two sources:
By default, ALL changes trigger snapshots. With skip_remote_changes: true, only local changes create timeline entries. This enables per-user undo stacks in collaborative apps.
// How it works:
// 1. User A makes a change → Snapshot captured (local)
// 2. Change syncs to User B via remote_state
// 3. User B's snapshot system sees _isFromRemote flag
// 4. User B's timeline does NOT get an entry (skipped)
// 5. User B's undo stack remains their own actions only
Timeline Database Architecture:
_use_ss_[snapshot_name]timelinetimestamp (ensures chronological ordering)Timeline Navigation API:
// All timeline functions are async and return Promises
async function demonstrateTimeline() {
// Get timeline information
const info = await Doh.use_timeline_info('document_history');
console.log(`Timeline has ${info.length} entries`);
console.log('Current position:', info.currentIndex); // Current timeline position (0-indexed)
console.log('Latest entry timestamp:', info.latest?.timestamp);
// Check undo/redo availability using currentIndex
const canUndo = info.currentIndex > 0;
const canRedo = info.currentIndex < info.length - 1;
// Navigate backwards (undo)
const undoSuccess = await Doh.use_timeline_undo('document_history');
if (undoSuccess) {
console.log('Successfully undid last change');
} else {
console.log('Nothing to undo');
}
// Navigate forwards (redo)
const redoSuccess = await Doh.use_timeline_redo('document_history');
if (redoSuccess) {
console.log('Successfully redid last change');
} else {
console.log('Nothing to redo');
}
// Jump to specific position
const gotoSuccess = await Doh.use_timeline_goto('document_history', 5);
if (gotoSuccess) {
console.log('Jumped to timeline position 5');
}
// Get all timeline entries (for debugging/inspection)
const allSnapshots = await Doh.use_get_timeline('document_history');
console.log(`Retrieved ${allSnapshots.length} timeline entries`);
// Get limited entries (most recent first)
const recentSnapshots = await Doh.use_get_timeline('document_history', 10);
console.log('10 most recent snapshots:', recentSnapshots);
// Clear entire timeline (clears records, keeps database)
await Doh.use_clear_timeline('document_history');
console.log('Timeline cleared');
// Delete entire timeline database (use with caution)
// WARNING: Can block if there are open connections to the database
await Doh.use_delete_timeline('document_history');
console.log('Timeline database deleted');
}
Note:
use_clear_timeline()clears all records but keeps the IndexedDB database intact. This is fast and won't block.use_delete_timeline()deletes the entire database, which can block if there are open connections (e.g., from an active snapshot). Preferuse_clear_timeline()in most cases.
Timeline Info Return Object:
const info = await Doh.use_timeline_info('document_history');
// Returns:
// {
// length: 5, // Total entries in timeline
// currentIndex: 3, // Current position (0-indexed), null if at end
// latest: { ... }, // Most recent snapshot object
// oldest: { ... } // Oldest snapshot object
// }
// currentIndex behavior:
// - Starts at null (meaning "at the latest entry")
// - After undo: currentIndex = length - 2 (points to previous entry)
// - After redo: currentIndex increments toward latest
// - After new change: resets to null (at latest)
Timeline Truncation (Redo Stack Clearing):
When you make a new change after undoing, all "future" entries (the redo stack) are automatically removed:
// Example: Timeline with 5 entries [A, B, C, D, E], currentIndex at end
state.document.content = 'A'; // Entry 0
await Doh.microtask();
state.document.content = 'B'; // Entry 1
await Doh.microtask();
state.document.content = 'C'; // Entry 2
await Doh.microtask();
// Timeline: [A, B, C], currentIndex = null (at end)
await Doh.use_timeline_undo('doc'); // Go to B
await Doh.use_timeline_undo('doc'); // Go to A
// Timeline: [A, B, C], currentIndex = 0
// Now make a NEW change...
state.document.content = 'X';
await Doh.microtask();
// Timeline is TRUNCATED: [A, X], currentIndex = null
// B and C are gone - redo stack cleared
Queue-Based Operations and Microtask Batching: The timeline system uses per-snapshot-name queues to ensure atomic operations and maintain chronological order. However, due to the USE system's microtask batching, rapid state changes to the same path create only one snapshot:
// ⚠️ Important: Rapid changes are batched - only creates ONE snapshot
state.document.content = 'Version 1';
state.document.content = 'Version 2';
state.document.content = 'Version 3';
// Result: Only one snapshot with 'Version 3' (final value)
// ✅ To create separate snapshots, wait for microtasks between changes:
state.document.content = 'Version 1';
await Doh.microtask(); // Let snapshot capture
state.document.content = 'Version 2';
await Doh.microtask(); // Let snapshot capture
state.document.content = 'Version 3';
await Doh.microtask(); // Let snapshot capture
// Result: Three separate snapshots
// ✅ Or change different paths (different triggers):
state.document.content = 'New content'; // Triggers content snapshot
state.document.title = 'New title'; // Triggers metadata snapshot
state.document.styles = { bold: true }; // Triggers formatting snapshot
// Result: Multiple snapshots if watching different paths
Browser History:
IndexedDB Timeline:
The save_as feature allows multiple snapshot definitions to share the same storage containers, creating unified undo/redo streams while maintaining separate triggers and captured states.
// Main document content snapshots
Doh.use_snapshot('document_content', {
triggers: { 'document.content': '*' },
states: ['document.content', 'document.cursor'],
in_timeline: 100
});
// Document metadata changes - share the same timeline
Doh.use_snapshot('document_metadata', {
triggers: { 'document.title': '*' },
states: ['document.title', 'document.metadata'],
save_as: 'document_content', // Store in shared timeline
in_timeline: 100
});
// Document formatting - also shares the timeline
Doh.use_snapshot('document_formatting', {
triggers: { 'document.styles': '*' },
states: ['document.styles', 'document.layout'],
save_as: 'document_content', // Same shared timeline
in_timeline: 100
});
// Now all three snapshots contribute to one unified undo/redo stream
await Doh.use_timeline_undo('document_content'); // Undoes latest from any of the three
// Example: Creating multiple entries in shared timeline
state.document.content = 'New content'; // Triggers document_content snapshot
await Doh.microtask(); // Let it process
state.document.title = 'New title'; // Triggers document_metadata snapshot
await Doh.microtask(); // Let it process
state.document.styles = { bold: true }; // Triggers document_formatting snapshot
// All stored in 'document_content' timeline in chronological order
Multiple Shared Timelines:
// Save to multiple shared containers
Doh.use_snapshot('user_action', {
triggers: { 'user.action': '*' },
states: ['user.profile', 'user.activity'],
save_as: ['document_content', 'user_activity'], // Store in both timelines
in_timeline: 50
});
Key Benefits:
For collaborative applications where multiple users edit shared state, you need per-user undo stacks. Each user undoes only their own actions; undos sync to other users as new changes.
Key Insight: When you undo, the restored state is no longer marked as "replaying," so it syncs to other clients via use_remote as a normal state change. Other clients see the undo as a new change—not as an undo operation.
Complete Implementation:
// Collaborative undo module (e.g., my_app_undo.js)
Doh.Module('my_app_undo', ['my_app_ui'], async function() {
const state = Doh.use_state();
// Track undo/redo availability for UI buttons
state.undo_info = { canUndo: false, canRedo: false };
// Wait for remote data to arrive before setting up snapshot
// This ensures initial snapshot captures real state, not empty data
if (!state.items || state.items.length === 0) {
await new Promise(resolve => {
const removeObserver = Doh.observe_use('items', ({ newValue }) => {
if (newValue && newValue.length > 0) {
removeObserver();
resolve();
}
});
// Timeout fallback to avoid hanging if no data arrives
setTimeout(() => {
removeObserver();
resolve();
}, 5000);
});
}
// Register snapshot for undo/redo
await Doh.use_snapshot('my_app_undo', {
triggers: { 'items': '*' },
states: ['items'], // Capture the collaborative data
skip_remote_changes: true, // Only capture LOCAL changes
in_timeline: 50, // Keep last 50 states
onSnapshot: async () => {
// Update availability after snapshot captured
const info = await Doh.use_timeline_info('my_app_undo');
state.undo_info.canUndo = info.length > 1;
state.undo_info.canRedo = false; // New action clears redo stack
},
onReplay: () => {
// Snapshot already contains computed/derived values
// No recomputation needed - just let state sync to other clients
}
});
// Expose undo/redo API globally
window.MyAppUndo = {
async undo() {
const success = await Doh.use_timeline_undo('my_app_undo');
if (success) {
await this._updateInfo();
}
return success;
},
async redo() {
const success = await Doh.use_timeline_redo('my_app_undo');
if (success) {
await this._updateInfo();
}
return success;
},
async _updateInfo() {
const info = await Doh.use_timeline_info('my_app_undo');
state.undo_info.canUndo = info.currentIndex > 0;
state.undo_info.canRedo = info.currentIndex < info.length - 1;
},
async getInfo() {
return await Doh.use_timeline_info('my_app_undo');
}
};
});
Keyboard Shortcuts (in UI module):
// Add after your app initializes
$(document).on('keydown', function(e) {
// Skip if focused in input/textarea
if (['INPUT', 'TEXTAREA'].includes(e.target.tagName)) return;
// Cmd/Ctrl+Z = Undo
if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
MyAppUndo?.undo();
}
// Cmd/Ctrl+Shift+Z or Cmd/Ctrl+Y = Redo
if ((e.metaKey || e.ctrlKey) && ((e.key === 'z' && e.shiftKey) || e.key === 'y')) {
e.preventDefault();
MyAppUndo?.redo();
}
});
How Multi-User Undo Works:
Timeline for User A: [initial] → [move node] → [add node]
Timeline for User B: [initial] → [delete node]
User A presses Ctrl+Z:
1. A's timeline replays previous snapshot (before "add node")
2. State restored locally
3. _isReplayingSnapshot cleared
4. Change syncs to server via use_remote
5. Server broadcasts to User B
6. B receives change with _isFromRemote flag
7. B's snapshot system ignores it (skip_remote_changes: true)
8. B sees A's undo, but B's undo stack is unchanged
Critical: Don't Recompute in onReplay:
// ❌ WRONG - causes N extra remote updates
onReplay: () => {
// This runs AFTER _isReplayingSnapshot is cleared
// Each property set here triggers a separate remote sync!
for (const item of state.items) {
item.computedValue = recompute(item); // N updates sent to server!
}
}
// ✅ CORRECT - snapshot already contains computed values
onReplay: () => {
// Snapshot captures full state including computed/derived values
// Just let the restored state sync naturally (1 update)
}
Package Configuration:
# my_app.doh.yaml
my_app:
load:
- my_app_permissions
- browser?? my_app_ui
- browser?? my_app_undo # After UI module
- nodejs?? my_app_routes
Complete implementation of a document editor with timeline-based undo/redo:
// Document state setup
const state = Doh.use_state({
document: {
content: '',
title: 'Untitled Document',
cursor: { line: 0, column: 0 },
metadata: { wordCount: 0, lastModified: Date.now() }
}
});
// Document history with timeline
Doh.use_snapshot('document_history', {
triggers: { 'document.content': '*' }, // Any content change
states: [
'document.content',
'document.cursor',
'document.metadata'
],
in_timeline: 100, // Keep last 100 changes
callback: function(snapshotData) {
const content = snapshotData.states['document.content'];
console.log(`Auto-saved: "${content.substring(0, 30)}..."`);
}
});
// Editor interface functions
const editor = {
// Check if undo is available
async canUndo() {
const info = await Doh.use_timeline_info('document_history');
return info.length > 1;
},
// Check if redo is available
async canRedo() {
const info = await Doh.use_timeline_info('document_history');
return info.currentIndex < info.length - 1;
},
// Undo last change
async undo() {
const success = await Doh.use_timeline_undo('document_history');
if (success) {
this.updateUI();
return true;
}
return false;
},
// Redo last undone change
async redo() {
const success = await Doh.use_timeline_redo('document_history');
if (success) {
this.updateUI();
return true;
}
return false;
},
// Update UI to reflect current state
updateUI() {
document.getElementById('content').value = state.document.content;
document.getElementById('title').value = state.document.title;
this.updateToolbar();
},
// Update toolbar button states
async updateToolbar() {
document.getElementById('undo-btn').disabled = !(await this.canUndo());
document.getElementById('redo-btn').disabled = !(await this.canRedo());
},
// Insert text at cursor
insertText(text) {
const cursor = state.document.cursor;
const content = state.document.content;
const before = content.substring(0, cursor.column);
const after = content.substring(cursor.column);
state.document.content = before + text + after;
state.document.cursor.column += text.length;
state.document.metadata.lastModified = Date.now();
}
};
// Keyboard shortcuts
document.addEventListener('keydown', async function(e) {
if (e.ctrlKey || e.metaKey) {
switch (e.key) {
case 'z':
if (e.shiftKey) {
e.preventDefault();
await editor.redo();
} else {
e.preventDefault();
await editor.undo();
}
break;
case 'y':
e.preventDefault();
await editor.redo();
break;
}
}
});
Comprehensive session backup and recovery system:
// Session state
const state = Doh.use_state({
session: {
userId: null,
startTime: null,
lastActivity: null,
preferences: {},
openTabs: [],
workspaceLayout: 'default'
},
user: {
profile: null,
isLoggedIn: false
}
});
// Session backup with multiple trigger conditions
Doh.use_snapshot('session_backup', {
triggers: {
'user.isLoggedIn': true, // When user logs in
'session.preferences': '*', // Any preference change
'session.workspaceLayout': '*', // Layout changes
'session.lastActivity': '*' // Activity updates
},
trigger_mode_or: true, // Any of these triggers a backup
states: {
'user.profile': null,
'session.preferences': {},
'session.startTime': Date.now,
'session.workspaceLayout': 'default',
'session.openTabs': [],
'app.version': '1.0.0' // Include app version for migration
},
in_timeline: 20, // Keep last 20 session states
callback: function(snapshot) {
console.log('Session backed up:', new Date(snapshot.timestamp));
}
});
// Application startup - restore last session
async function restoreSession() {
try {
const sessions = await Doh.use_get_timeline('session_backup');
if (sessions.length > 0) {
const lastSession = sessions[sessions.length - 1];
// Check version compatibility
const savedVersion = lastSession.states['app.version'];
const currentVersion = '1.0.0';
if (savedVersion === currentVersion) {
// Restore the last session
await Doh.use_replay_snapshot(lastSession);
console.log('Session restored from:', new Date(lastSession.timestamp));
return true;
} else {
console.log('Session version mismatch, starting fresh');
return false;
}
}
} catch (error) {
console.warn('Failed to restore session:', error);
}
return false;
}
// Activity tracking
function trackActivity(activityType) {
state.session.lastActivity = Date.now();
// This will trigger a session backup due to OR logic
}
// Graceful shutdown - manual session save
window.addEventListener('beforeunload', function() {
// Force a final session backup
Doh.use_trigger_snapshot('session_backup');
});
// Initialize on app start
window.addEventListener('load', async function() {
const restored = await restoreSession();
if (!restored) {
// Initialize fresh session
state.session.startTime = Date.now();
state.session.preferences = getDefaultPreferences();
}
});
Using browser history for application navigation state. With automatic popstate handling, no manual event listener is needed:
// Navigation state
const state = Doh.use_state({
router: {
currentRoute: '/',
params: {},
query: {}
}
});
// Navigation snapshot with browser history
// Popstate handling is automatic - no event listener needed!
Doh.use_snapshot('navigation_history', {
triggers: {
'router.currentRoute': '*', // Any route change
'router.query': '*' // Any query parameter change
},
states: [
'router.currentRoute',
'router.params',
'router.query',
'user.context' // Include user context for route-specific state
],
as_history: true, // Store in browser history (enables auto popstate)
callback: function(snapshot) {
console.log('Navigation saved:', snapshot.states['router.currentRoute']);
}
});
// Simple navigation function - just update state
function navigate(route, params = {}, query = {}) {
state.router.currentRoute = route;
state.router.params = params;
state.router.query = query;
// Snapshot triggers automatically, adds to browser history
// Back/forward navigation is handled automatically by Doh
}
// Usage examples
navigate('/users', {}, { page: 1, filter: 'active' });
navigate('/users/123', { id: 123 });
navigate('/settings');
// Browser back/forward automatically restores state - no code needed!
Create snapshots on demand without automatic triggers:
// Define snapshot without automatic triggers
Doh.use_snapshot('manual_checkpoint', {
states: ['player.position', 'game.inventory', 'world.state']
// No triggers - manual only
});
// Trigger manually when needed
function createCheckpoint() {
const snapshot = Doh.use_trigger_snapshot('manual_checkpoint');
console.log('Checkpoint created:', snapshot.timestamp);
return snapshot;
}
// Manual triggering with dynamic state selection
function createCustomSnapshot(statePaths) {
const snapshotName = `custom_${Date.now()}`;
Doh.use_snapshot(snapshotName, {
states: statePaths,
in_timeline: 1 // Just store this one snapshot
});
return Doh.use_trigger_snapshot(snapshotName);
}
// Get and replay specific snapshots
async function demonstrateReplay() {
// Get all snapshots for inspection
const snapshots = await Doh.use_get_timeline('document_history');
console.log(`Found ${snapshots.length} snapshots`);
// Show snapshot info
snapshots.forEach((snapshot, index) => {
console.log(`${index}: ${new Date(snapshot.timestamp)} - Trigger #${snapshot.triggerCount}`);
});
// Replay specific snapshot
if (snapshots.length > 0) {
const targetSnapshot = snapshots[2]; // Third snapshot
await Doh.use_replay_snapshot(targetSnapshot);
console.log('State restored to snapshot #2');
}
// Replay with URL query clearing (useful for clean state restoration)
if (snapshots.length > 0) {
const cleanSnapshot = snapshots[0]; // First snapshot
await Doh.use_replay_snapshot(cleanSnapshot, { clear_url_query: true });
console.log('State restored with clean URL (all query parameters cleared)');
}
// If snapshots have saved URL query, they restore automatically
if (snapshots.length > 0 && snapshots[0].url_query) {
await Doh.use_replay_snapshot(snapshots[0]); // Restores saved URL query
console.log('State and URL restored from saved query parameters:', snapshots[0].url_query);
}
// Navigate through timeline
await Doh.use_timeline_goto('document_history', 0); // Go to first
await Doh.use_timeline_goto('document_history', -1); // Go to last
// Navigate with URL clearing - useful for clean state restoration
await Doh.use_timeline_goto('document_history', 0, { clear_url_query: true }); // Go to first with clean URL
await Doh.use_timeline_undo('document_history', { clear_url_query: true }); // Undo with clean URL
await Doh.use_timeline_redo('document_history', { clear_url_query: true }); // Redo with clean URL
// Step through manually
let position = 0;
while (await Doh.use_timeline_goto('document_history', position)) {
console.log(`Viewing snapshot at position ${position}`);
position++;
}
}
The snapshot system provides completion callbacks that enable reliable async coordination without arbitrary timeouts. Understanding when each callback fires is essential for proper usage.
Callback Execution Order:
1. State change triggers observer
↓
2. Snapshot queued for processing
↓
3. Snapshot captured (states extracted)
↓
4. `callback()` fires ← During capture phase
↓
5. IndexedDB storage operations (if in_timeline: true)
↓
6. All storage operations complete
↓
7. `onSnapshot()` fires ← After ALL operations complete
Detailed Examples:
// Track completion across the entire async pipeline
let captureStarted = false;
let storageComplete = false;
await Doh.use_snapshot('async_tracking', {
triggers: { 'document.content': '*' },
states: ['document.content', 'document.metadata'],
in_timeline: true,
// Fires DURING capture (before storage)
callback: function(snapshotData) {
captureStarted = true;
console.log('Captured:', snapshotData.states['document.content']);
// At this point: snapshot data extracted, storage NOT complete
},
// Fires AFTER all async operations complete
onSnapshot: function(snapshotData) {
storageComplete = true;
console.log('Fully stored in IndexedDB');
// At this point: timeline updated, safe to query use_timeline_info()
}
});
// Trigger a change
state.document.content = 'Hello World';
await Doh.microtask();
// Wait for full completion
while (!storageComplete) await Doh.microtask();
// Now timeline queries are guaranteed accurate
const info = await Doh.use_timeline_info('async_tracking');
console.log(`Timeline length: ${info.length}`); // Accurate!
Replay Completion Tracking:
let replayComplete = false;
await Doh.use_snapshot('replay_tracking', {
triggers: { 'state.value': '*' },
states: ['state.value', 'state.metadata'],
in_timeline: true,
onSnapshot: function(data) {
console.log('Snapshot stored');
},
onReplay: function(data) {
replayComplete = true;
console.log('Replay complete, state restored');
// Safe to update UI, run assertions, etc.
}
});
// Create some history
state.state = { value: 'Version 1', metadata: {} };
await Doh.microtask();
state.state.value = 'Version 2';
await Doh.microtask();
// Undo and wait for completion
replayComplete = false;
await Doh.use_timeline_undo('replay_tracking');
while (!replayComplete) await Doh.microtask();
// State is now guaranteed restored to Version 1
Test Pattern: Robust Waiting
// Helper function for waiting on snapshot completion
async function waitForSnapshotComplete(flagRef, timeout = 2000) {
const start = Date.now();
while (!flagRef.value && (Date.now() - start) < timeout) {
await Doh.microtask();
}
if (!flagRef.value) {
throw new Error('Snapshot did not complete within timeout');
}
flagRef.value = false; // Reset for next use
}
// Usage in tests
const snapshotFlag = { value: false };
await Doh.use_snapshot('test_snapshot', {
triggers: { 'document.content': '*' },
states: ['document.content'],
in_timeline: true,
onSnapshot: () => { snapshotFlag.value = true; }
});
// Make changes and wait for each
state.document.content = 'First';
await waitForSnapshotComplete(snapshotFlag);
let info = await Doh.use_timeline_info('test_snapshot');
console.assert(info.length === 2, 'Should have 2 entries (initial + first)');
state.document.content = 'Second';
await waitForSnapshotComplete(snapshotFlag);
info = await Doh.use_timeline_info('test_snapshot');
console.assert(info.length === 3, 'Should have 3 entries');
Async Callbacks Support:
All callbacks can be async functions:
await Doh.use_snapshot('async_callbacks', {
triggers: { 'data': '*' },
states: ['data'],
in_timeline: true,
// Async callback for processing during capture
callback: async function(snapshotData) {
await performValidation(snapshotData.states.data);
console.log('Validation complete');
},
// Async completion notification
onSnapshot: async function(snapshotData) {
await updateRemoteBackup(snapshotData);
await notifyObservers('snapshot_saved');
console.log('All post-storage tasks complete');
},
// Async replay notification
onReplay: async function(snapshotData) {
await refreshUI(snapshotData.states);
await recalculateDependencies();
console.log('Replay and UI refresh complete');
}
});
Key Differences:
| Callback | Timing | Purpose | Storage Complete? |
|---|---|---|---|
callback |
During capture | Process snapshot data, logging | ❌ No |
onSnapshot |
After all storage | Signal completion, tests, coordination | ✅ Yes |
onReplay |
After replay complete | Update UI, signal test completion | ✅ Yes |
Common Patterns:
// Pattern 1: Test synchronization
let done = false;
await Doh.use_snapshot('test', {
triggers: { 'x': '*' },
states: ['x'],
onSnapshot: () => { done = true; }
});
state.x = 'value';
while (!done) await Doh.microtask();
// Test assertions here
// Pattern 2: UI updates after replay
await Doh.use_snapshot('ui_state', {
triggers: { 'ui': '*' },
states: ['ui'],
onReplay: async function(data) {
await updateDOMFromState(data.states.ui);
showNotification('State restored');
}
});
// Pattern 3: Progress tracking
let saveCount = 0;
await Doh.use_snapshot('autosave', {
triggers: { 'document.content': '*' },
states: ['document.content'],
in_timeline: true,
callback: () => {
showStatus('Saving...');
},
onSnapshot: () => {
saveCount++;
showStatus(`Saved (${saveCount} times)`);
}
});
// Comprehensive cleanup functions
const snapshotManager = {
// Remove snapshot definition (stops watching, keeps data)
removeDefinition(name) {
Doh.use_remove_snapshot(name);
console.log(`Stopped watching ${name}, data preserved`);
},
// Remove definition and clear all stored data
async purgeSnapshot(name, clearStorage = true) {
Doh.use_remove_snapshot(name, clearStorage);
if (clearStorage) {
// Also clear timeline if it exists
try {
await Doh.use_clear_timeline(name);
console.log(`Purged ${name} completely`);
} catch (error) {
console.log(`Purged ${name} (no timeline data)`);
}
}
},
// Get snapshot statistics
async getSnapshotStats(name) {
try {
const info = await Doh.use_timeline_info(name);
const snapshots = await Doh.use_get_timeline(name, 5); // Last 5 for analysis
let totalSize = 0;
let avgTriggerCount = 0;
snapshots.forEach(snapshot => {
totalSize += JSON.stringify(snapshot).length;
avgTriggerCount += snapshot.triggerCount;
});
return {
name: name,
totalEntries: info.length,
latestTimestamp: info.latest?.timestamp,
avgSize: snapshots.length > 0 ? Math.round(totalSize / snapshots.length) : 0,
avgTriggerCount: snapshots.length > 0 ? Math.round(avgTriggerCount / snapshots.length) : 0,
estimatedTotalSize: Math.round((totalSize / snapshots.length) * info.length) || 0
};
} catch (error) {
return { name: name, error: error.message };
}
},
// Analyze all active snapshots
async analyzeAllSnapshots() {
const activeSnapshots = Array.from(Doh._snapshots.keys());
const stats = [];
for (const name of activeSnapshots) {
const stat = await this.getSnapshotStats(name);
stats.push(stat);
}
console.table(stats);
return stats;
}
};
// Usage examples
await snapshotManager.analyzeAllSnapshots();
const stats = await snapshotManager.getSnapshotStats('document_history');
console.log('Document history stats:', stats);
The USE snapshot system uses per-snapshot-name operation queues that provide several performance advantages:
// Multiple rapid changes are handled gracefully
state.document.content = 'Version 1';
state.document.content = 'Version 2';
state.document.content = 'Version 3';
state.document.content = 'Version 4';
// All versions will be queued and stored in order
// No timestamp collisions or lost snapshots
// Each waits for the previous to complete
// Meanwhile, other snapshots can operate in parallel
state.user.preferences.theme = 'dark'; // Different queue
state.game.level = 5; // Different queue
// Best practices for memory efficiency
// 1. Use appropriate storage backends
Doh.use_snapshot('navigation_state', {
triggers: 'router.currentRoute',
states: ['router.currentRoute', 'router.params'],
as_history: true // Lightweight navigation, use browser history
});
Doh.use_snapshot('heavy_document', {
triggers: 'document.content',
states: ['document.content', 'document.metadata'],
in_timeline: 50 // Large data, use IndexedDB with rotation
});
// 2. Limit captured state to essentials
Doh.use_snapshot('efficient_user', {
triggers: 'user.profile.name',
states: ['user.profile.name', 'user.profile.email'], // Only what's needed
// Don't capture: user.cache, user.temporaryData, user.ui
});
// 3. Use rotation to prevent unbounded growth
Doh.use_snapshot('bounded_history', {
triggers: 'document.content',
states: 'document.content',
in_timeline: 100 // Automatically removes entries beyond 100
});
// 4. Clean up unused snapshots
function cleanupOldSnapshots() {
const unusedSnapshots = ['temp_debug', 'test_scenario', 'old_feature'];
unusedSnapshots.forEach(name => {
Doh.use_remove_snapshot(name, true); // Remove definition + clear storage
});
}
// Efficient trigger strategies
// 1. Use specific values instead of wildcards when possible
Doh.use_snapshot('optimized_saves', {
triggers: {
'game.status': 'completed', // Specific value - efficient
'user.authenticated': true // Specific value - efficient
},
// Instead of: 'game.status': '*' (triggers on any change)
});
// 2. Combine related triggers with AND logic
Doh.use_snapshot('compound_condition', {
triggers: {
'user.loggedIn': true, // Must be logged in
'document.hasChanges': true // AND must have changes
},
// More efficient than separate snapshots for each condition
});
// 3. Use custom functions for complex logic
Doh.use_snapshot('smart_saving', {
triggers: { 'document.modified': true, 'user.activity': '*' },
trigger_mode_or: function(triggers) {
// Only save if user is active AND document was modified recently
const modified = triggers['document.modified'];
const activity = triggers['user.activity'];
if (modified.met && activity.met) {
const timeSinceModification = Date.now() - modified.timestamp;
return timeSinceModification < 60000; // Within last minute
}
return false;
}
});
Snapshots integrate seamlessly with all USE state features:
// URL-synchronized state with snapshots
const state = Doh.use_state();
// URL sync
Doh.use_url_query(['page', 'query', 'filters']);
// Snapshot navigation state for browser history
Doh.use_snapshot('navigation_history', {
triggers: ['page', 'query', 'filters'],
states: ['page', 'query', 'filters', 'user.context'],
as_history: true // Browser back/forward integration
});
// Changes to URL parameters automatically trigger snapshots
state.page = 2; // Updates URL AND creates snapshot
state.query = 'search term'; // Updates URL AND creates snapshot
// Persistent state with snapshots
Doh.use_local_storage(['userPrefs', 'workspaceLayout']);
// Snapshot preference changes
Doh.use_snapshot('preference_history', {
triggers: ['userPrefs', 'workspaceLayout'],
states: ['userPrefs', 'workspaceLayout', 'app.version'],
in_timeline: 20 // Keep preference history
});
// Changes automatically save to localStorage AND create snapshots
state.userPrefs.theme = 'dark'; // Persists in localStorage + snapshot
state.workspaceLayout = 'sidebar'; // Persists in localStorage + snapshot
// Pattern-driven UI with snapshots
Doh.use_state_pattern('todoItems');
// Snapshot UI state changes
Doh.use_snapshot('ui_history', {
triggers: 'todoItems',
states: ['todoItems', 'ui.selectedItem', 'ui.filter'],
in_timeline: 30
});
// Adding/removing todo items triggers both pattern updates AND snapshots
state.todoItems.push({ pattern: 'TodoItem', text: 'New task', id: Date.now() });
Critical: Timeline operations return Promises and must be awaited:
// ✅ Correct - using await
const info = await Doh.use_timeline_info('my_snapshot');
const success = await Doh.use_timeline_undo('my_snapshot');
const snapshots = await Doh.use_get_timeline('my_snapshot');
// ✅ Correct - using .then()
Doh.use_timeline_info('my_snapshot').then(info => {
console.log(`Timeline has ${info.length} entries`);
});
// ❌ Incorrect - missing await
const info = Doh.use_timeline_info('my_snapshot'); // Returns Promise, not info
console.log(info.length); // Error: Promise doesn't have length property
// ❌ Incorrect - treating as synchronous
const snapshots = Doh.use_get_timeline('my_snapshot'); // Returns Promise
snapshots.forEach(snap => console.log(snap)); // Error: Promise doesn't have forEach
// Snapshot triggers are async - use microtask to wait for completion
state.document.content = 'New content';
// Wait for snapshot to be captured
await Doh.microtask();
// Now the snapshot has been processed and stored
const snapshots = await Doh.use_get_timeline('document_history');
console.log('Latest snapshot captured'); // Includes the new content
async function safeTimelineOperation() {
try {
const success = await Doh.use_timeline_undo('document_history');
if (success) {
console.log('Undo successful');
} else {
console.log('Nothing to undo');
}
} catch (error) {
console.error('Timeline operation failed:', error);
// Handle error gracefully - maybe disable undo button
}
}
// Robust snapshot management
async function robustSnapshotManagement() {
try {
// Check if timeline exists and has content
const info = await Doh.use_timeline_info('document_history');
if (info.length === 0) {
console.log('No snapshots available');
return false;
}
// Perform operation
const result = await Doh.use_timeline_undo('document_history');
return result;
} catch (error) {
if (error.name === 'DatabaseError') {
console.log('Timeline database not available');
} else {
console.error('Unexpected error:', error);
}
return false;
}
}
The USE state locking system (use_lock) interacts with snapshots in important ways:
Locks are enforced during snapshot replay. When you replay a snapshot (via use_replay_snapshot, use_timeline_undo, use_timeline_redo, or use_timeline_goto), any locked paths in the snapshot will not be restored:
const state = Doh.use_state({ data: { value: 'original', locked: 'protected' } });
// Take a snapshot
await Doh.use_snapshot('example', {
triggers: { 'data': '*' },
states: ['data.value', 'data.locked'],
in_timeline: true
});
// Make changes
state.data.value = 'changed';
state.data.locked = 'also changed';
await Doh.microtask();
// Lock one path
Doh.use_lock('data.locked');
// Undo - only unlocked paths are restored
await Doh.use_timeline_undo('example');
console.log(state.data.value); // 'original' (restored)
console.log(state.data.locked); // 'also changed' (lock prevented restore)
To restore a locked path via snapshot:
// Temporarily unlock to restore
Doh.use_unlock('data.locked');
await Doh.use_timeline_undo('example');
Doh.use_lock('data.locked'); // Re-apply lock
Locks themselves are not captured in snapshots - only state values are captured. This means:
When using skip_remote_changes: true for collaborative undo, locks provide additional protection:
// Server locks a critical field
// (via remote_state on server)
// Client's undo operation respects the lock
await Doh.use_timeline_undo('document');
// Locked paths stay protected, unlocked paths are restored
The USE snapshot system provides a comprehensive solution for state capture and time travel with fully asynchronous top-to-bottom architecture:
onSnapshot and onReplay callbacks eliminate arbitrary timeoutscurrentIndex, and automatic redo stack clearing on new changesskip_remote_changes option enables per-user undo stacks in multi-user applicationsuse_remoteKey Async Principles:
onSnapshot/onReplay callbacks instead of arbitrary timeoutsskip_remote_changes: true skips snapshots for changes from other usersTimeline Position Tracking:
use_timeline_info() returns currentIndex for undo/redo availabilitycurrentIndex > 0 means undo is availablecurrentIndex < length - 1 means redo is availableThe system scales from simple navigation tracking to complex collaborative document editing workflows, providing reliable, non-blocking state management for modern web applications.