Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 152 additions & 2 deletions src/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ export const INTERNAL_CONFIRMATIONS_KEY = 'experiments.confirmationsInternal';

export const DEFAULT_QUEUE_LIMIT = 50;

// Storage type constants
export const STORAGE_TYPE_USER = 'user';
export const STORAGE_TYPE_SESSION = 'session';
export const STORAGE_TYPE_NONE = 'none';

/**
* The EvolvContext provides functionality to manage data relating to the client state, or context in which the
* variants will be applied.
Expand All @@ -27,6 +32,86 @@ function EvolvContext(store) {
let remoteContext;
let localContext;
let initialized = false;
let persistenceMapping = {}; // Maps keys to storage types

// Storage utility functions
function getStoragePrefix() {
return 'evolv_' + (uid || 'default') + '_';
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not make the function resolve the whole name for you. Example:

  function getStorageKey(key) {
    return 'evolv_' + (uid || 'default') + '_' + key;
  }


function saveToStorage(key, value, storageType, isLocal) {
if (!storageType || storageType === STORAGE_TYPE_NONE) return;

const storageKey = getStoragePrefix() + key;
const storageData = {
value: value,
isLocal: !!isLocal
};
const serializedValue = JSON.stringify(storageData);

try {
if (storageType === STORAGE_TYPE_USER && typeof localStorage !== 'undefined') {
localStorage.setItem(storageKey, serializedValue);
} else if (storageType === STORAGE_TYPE_SESSION && typeof sessionStorage !== 'undefined') {
sessionStorage.setItem(storageKey, serializedValue);
}
} catch (e) {
// Silently fail if storage is not available or quota exceeded
console.warn('Evolv: Failed to save to storage:', e.message);
}
}

function loadFromStorage(key, storageType) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indentation

if (!storageType || storageType === STORAGE_TYPE_NONE) return undefined;

const storageKey = getStoragePrefix() + key;

try {
let storedValue;
if (storageType === STORAGE_TYPE_USER && typeof localStorage !== 'undefined') {
storedValue = localStorage.getItem(storageKey);
} else if (storageType === STORAGE_TYPE_SESSION && typeof sessionStorage !== 'undefined') {
storedValue = sessionStorage.getItem(storageKey);
}

if (!storedValue) return undefined;

return JSON.parse(storedValue); // Expected format: {value: actualValue, isLocal: boolean}
} catch (e) {
// Silently fail if storage is not available or value is not parseable
console.warn('Evolv: Failed to load from storage:', e.message);
return undefined;
}
}

function removeFromStorage(key, storageType) {
if (!storageType || storageType === STORAGE_TYPE_NONE) return;

const storageKey = getStoragePrefix() + key;

try {
if (storageType === STORAGE_TYPE_USER && typeof localStorage !== 'undefined') {
localStorage.removeItem(storageKey);
} else if (storageType === STORAGE_TYPE_SESSION && typeof sessionStorage !== 'undefined') {
sessionStorage.removeItem(storageKey);
}
} catch (e) {
// Silently fail if storage is not available
console.warn('Evolv: Failed to remove from storage:', e.message);
}
}

function loadPersistedData() {
Object.keys(persistenceMapping).forEach(function(key) {
const storageType = persistenceMapping[key];
const storageData = loadFromStorage(key, storageType);

if (storageData !== undefined) {
const targetContext = storageData.isLocal ? localContext : remoteContext;
objects.setKeyToValue(key, storageData.value, targetContext);
}
});
}

/**
* A unique identifier for the participant.
Expand Down Expand Up @@ -60,6 +145,10 @@ function EvolvContext(store) {
uid = _uid;
remoteContext = _remoteContext ? objects.deepClone(_remoteContext) : {};
localContext = _localContext ? objects.deepClone(_localContext) : {};

// Load any persisted data
loadPersistedData();

initialized = true;
emit(this, CONTEXT_INITIALIZED, this.resolve());
};
Expand Down Expand Up @@ -100,6 +189,11 @@ function EvolvContext(store) {

objects.setKeyToValue(key, value, context);

// Handle persistence if configured
if (persistenceMapping[key]) {
saveToStorage(key, value, persistenceMapping[key], local);
}

const updated = this.resolve();
if (typeof before === 'undefined') {
emit(this, CONTEXT_VALUE_ADDED, key, value, local, updated);
Expand Down Expand Up @@ -141,6 +235,13 @@ function EvolvContext(store) {
context = remoteContext;
}

// Handle persistence for all updated keys
Object.keys(flattened).forEach(function(key) {
if (persistenceMapping[key]) {
saveToStorage(key, flattened[key], persistenceMapping[key], local);
}
});

const thisRef = this;
const updated = this.resolve();
Object.keys(flattened).forEach(function(key) {
Expand All @@ -163,11 +264,16 @@ function EvolvContext(store) {
*/
this.remove = function(key) {
ensureInitialized();
const local = objects.removeValueForKey(key, localContext);
const local = objects.removeValueForKey(key, localContext);
const remote = objects.removeValueForKey(key, remoteContext);
const removed = local || remote;

if (removed) {
// Remove from persistent storage if key was removed
if (persistenceMapping[key]) {
removeFromStorage(key, persistenceMapping[key]);
}

const updated = this.resolve();
emit(this, CONTEXT_VALUE_REMOVED, key, !remote, updated);
emit(this, CONTEXT_CHANGED, updated);
Expand Down Expand Up @@ -230,7 +336,51 @@ function EvolvContext(store) {
const newArray = combined.slice(combined.length - limit);

return this.set(key, newArray, local);
}
};

/**
* Configures persistence for a specific key.
*
* @param {String} key The key to configure persistence for.
* @param {String} storageType The storage type: 'user' (localStorage), 'session' (sessionStorage), or 'none'.
*/
this.setPersistence = function(key, storageType) {
if (!key || typeof key !== 'string') {
throw new Error('Evolv: Key must be a non-empty string');
}

if (![STORAGE_TYPE_USER, STORAGE_TYPE_SESSION, STORAGE_TYPE_NONE].includes(storageType)) {
throw new Error('Evolv: Storage type must be "user", "session", or "none"');
}

if (storageType === STORAGE_TYPE_NONE) {
// Remove persistence mapping
delete persistenceMapping[key];
// Remove any existing stored data
removeFromStorage(key, STORAGE_TYPE_USER);
removeFromStorage(key, STORAGE_TYPE_SESSION);
} else {
// Set new persistence mapping
persistenceMapping[key] = storageType;

// If the key already exists in context and context is initialized, save it with the new storage type
if (initialized) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor potential bug -- you can configure a key for sessionStorage and then for localStorage, and it will persist them in both.
Lines 360-361 look like the most consistent behavior would be to clear the other storage types first

// Check if value exists in local context first, then remote
let currentValue, isLocal;
if (objects.hasKey(key, localContext)) {
currentValue = objects.getValueForKey(key, localContext);
isLocal = true;
} else if (objects.hasKey(key, remoteContext)) {
currentValue = objects.getValueForKey(key, remoteContext);
isLocal = false;
}

if (currentValue !== undefined) {
saveToStorage(key, currentValue, storageType, isLocal);
}
}
}
};
}

export default EvolvContext;
131 changes: 130 additions & 1 deletion src/tests/context.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import chai from 'chai';
import Context, { CONTEXT_VALUE_ADDED, CONTEXT_VALUE_CHANGED } from '../context.js';
import Context, { CONTEXT_VALUE_ADDED, CONTEXT_VALUE_CHANGED, STORAGE_TYPE_USER, STORAGE_TYPE_SESSION, STORAGE_TYPE_NONE } from '../context.js';
import Store from '../store.js';
import { waitFor } from '../waitforit.js';

Expand Down Expand Up @@ -36,6 +36,135 @@ describe('context', () => {
});
});

describe('persistence', () => {
let context;
beforeEach(() => {
context = new Context();
context.initialize('test-persistence', {}, {});
// Clear any existing stored data
if (typeof localStorage !== 'undefined') {
localStorage.clear();
}
if (typeof sessionStorage !== 'undefined') {
sessionStorage.clear();
}
});

it('should configure persistence for a key', () => {
expect(() => context.setPersistence('user.preference', STORAGE_TYPE_USER)).to.not.throw();
expect(() => context.setPersistence('session.data', STORAGE_TYPE_SESSION)).to.not.throw();
expect(() => context.setPersistence('temp.data', STORAGE_TYPE_NONE)).to.not.throw();
});

it('should throw error for invalid key', () => {
expect(() => context.setPersistence('', STORAGE_TYPE_USER)).to.throw('Evolv: Key must be a non-empty string');
expect(() => context.setPersistence(null, STORAGE_TYPE_USER)).to.throw('Evolv: Key must be a non-empty string');
});

it('should throw error for invalid storage type', () => {
expect(() => context.setPersistence('test.key', 'invalid')).to.throw('Evolv: Storage type must be "user", "session", or "none"');
});

it('should save and retrieve data with persistence disabled by default', () => {
context.set('test.key', 'test-value');
expect(context.get('test.key')).to.equal('test-value');

// Create new context instance to simulate page reload
const newContext = new Context();
newContext.initialize('test-persistence', {}, {});
expect(newContext.get('test.key')).to.be.undefined;
});

// Note: These tests would work in a browser environment with localStorage/sessionStorage
// In Node.js test environment, they will just verify the API works without throwing errors
it('should handle storage operations gracefully in non-browser environment', () => {
expect(() => {
context.setPersistence('user.data', STORAGE_TYPE_USER);
context.set('user.data', { id: 123, name: 'Test User' });
}).to.not.throw();

expect(() => {
context.setPersistence('session.data', STORAGE_TYPE_SESSION);
context.set('session.data', [1, 2, 3]);
}).to.not.throw();
});

it('should remove persistence configuration when set to none', () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better title is 'should not clear the context when persistence removed'?
This doesn't test that it actually removed persistence

context.setPersistence('test.key', STORAGE_TYPE_USER);
context.set('test.key', 'value');

// Remove persistence
context.setPersistence('test.key', STORAGE_TYPE_NONE);

// This should work without throwing errors
expect(context.get('test.key')).to.equal('value');
});

it('should persist local context values and restore them to local context', () => {
context.setPersistence('local.key', STORAGE_TYPE_USER);
context.set('local.key', 'local-value', true); // Set as local

expect(context.get('local.key')).to.equal('local-value');

// Verify the value was persisted and can be restored to local context
// In a browser environment, this would actually work with localStorage
// In Node.js, we just verify the API works without throwing errors
expect(() => {
const newContext = new Context();
newContext.setPersistence('local.key', STORAGE_TYPE_USER);
newContext.initialize('test-persistence', {}, {});
}).to.not.throw();
});

it('should restore local and remote values to correct contexts with mock storage', () => {
// Mock localStorage for this test
const mockStorage = {};
const originalLocalStorage = global.localStorage;

global.localStorage = {
setItem: (key, value) => { mockStorage[key] = value; },
getItem: (key) => mockStorage[key] || null,
removeItem: (key) => { delete mockStorage[key]; },
clear: () => { for (let key in mockStorage) delete mockStorage[key]; }
};

try {
const ctx1 = new Context();
ctx1.initialize('test-user', {}, {});

// Configure persistence for both local and remote values
ctx1.setPersistence('remote.value', STORAGE_TYPE_USER);
ctx1.setPersistence('local.value', STORAGE_TYPE_USER);

// Set values in different contexts
ctx1.set('remote.value', 'remote-data', false); // remote context
ctx1.set('local.value', 'local-data', true); // local context

// Create new context to simulate page reload
const ctx2 = new Context();
ctx2.setPersistence('remote.value', STORAGE_TYPE_USER);
ctx2.setPersistence('local.value', STORAGE_TYPE_USER);
ctx2.initialize('test-user', {}, {});

// Verify values are restored to correct contexts
expect(ctx2.get('remote.value')).to.equal('remote-data');
expect(ctx2.get('local.value')).to.equal('local-data');

// Verify they're in the correct underlying contexts
expect(ctx2.remoteContext.remote.value).to.equal('remote-data');
expect(ctx2.localContext.local.value).to.equal('local-data');

// Local value should not be in remote context and vice versa
expect(ctx2.remoteContext.local).to.be.undefined;
expect(ctx2.localContext.remote).to.be.undefined;

} finally {
// Restore original localStorage
global.localStorage = originalLocalStorage;
}
});
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would love to see some ordering tests -

  • configurePersistent before initialize
  • set before configure

And Removing from persistence


describe('update', () => {
const options = {
version: 1,
Expand Down
3 changes: 3 additions & 0 deletions src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,8 @@ export interface WebRemoteContext extends RemoteContext {

export interface LocalContext extends Record<string, any> {}

export type StorageType = 'user' | 'session' | 'none';

export class EvolvContext {
uid: string;
remoteContext: RemoteContext | WebRemoteContext;
Expand All @@ -171,6 +173,7 @@ export class EvolvContext {
get<T = any>(key: string): T;
contains(key: string): boolean;
pushToArray(key: string, value: any, local?: boolean, limit?: number): boolean;
setPersistence(key: string, storageType: StorageType): void;
}

export default EvolvClient;