Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
4 changes: 2 additions & 2 deletions modules/observable-store/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[![Build Status](https://travis-ci.org/DanWahlin/Observable-Store.svg?branch=master)](https://travis-ci.org/DanWahlin/Observable-Store)
[![npm version](https://badge.fury.io/js/%40codewithdan%2Fobservable-store.svg)](https://www.npmjs.com/package/@codewithdan/observable-store)
[![Node.js CI](https://github.com/DanWahlin/Observable-Store/actions/workflows/nodejs-build-validation.yml/badge.svg)](https://github.com/DanWahlin/Observable-Store/actions/workflows/nodejs-build-validation.yml)
[![npm version](https://img.shields.io/npm/v/@codewithdan/observable-store?color=%23330C252&label=npm%20version)](https://www.npmjs.com/package/@codewithdan/observable-store)

## Observable Store - State Management for Front-End Applications (Angular, React, Vue.js, or any other)

Expand Down
14 changes: 14 additions & 0 deletions modules/observable-store/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions modules/observable-store/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,12 @@
"devDependencies": {
"@types/jasmine": "^3.8.2",
"@types/node": "^18.11.9",
"dayjs": "^1.11.19",
"jasmine": "^3.9.0",
"jasmine-spec-reporter": "^7.0.0",
"jasmine-ts": "^0.4.0",
"rxjs": "^7.5.7",
"ts-node": "^10.2.1",
"typescript": "4.9.3",
"rxjs": "^7.5.7"
"typescript": "4.9.3"
}
}
8 changes: 7 additions & 1 deletion modules/observable-store/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,11 @@
"es2015",
"es2017"
]
}
},
"exclude": [
"**/*.spec.ts",
"spec",
"tests",
"node_modules"
]
}
122 changes: 122 additions & 0 deletions modules/observable-store/utilities/cloner.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,31 @@
import { ClonerService } from './cloner.service';
import dayjs from 'dayjs';

class FakeClass {
constructor(public prop1: string, public prop2: string) {}
}

// Mock complex class similar to Dayjs/Moment with toJSON method
class ComplexDateClass {
private internalDate: Date;

constructor(dateStr: string) {
this.internalDate = new Date(dateStr);
}

format(): string {
return this.internalDate.toISOString();
}

toJSON(): string {
return this.internalDate.toISOString();
}

getTime(): number {
return this.internalDate.getTime();
}
}

describe('ClonerService', () => {
it('should clone a class', () => {
const fake = new FakeClass('foo', 'bar');
Expand Down Expand Up @@ -60,4 +82,104 @@ describe('ClonerService', () => {
expect(clonedObject.fake.prop1).toEqual('foo');
expect(clonedObject.fake.prop2).toEqual('bar');
});

it('should handle Dayjs objects by returning reference', () => {
const testObject = {
name: 'Test',
date: dayjs('2019-12-31'),
nested: {
anotherDate: dayjs('2020-01-01')
}
};

const cloneService = new ClonerService();
const clonedObject = cloneService.deepClone(testObject);

// The object structure is cloned
expect(clonedObject).not.toBe(testObject);
expect(clonedObject.name).toEqual('Test');
expect(clonedObject.nested).not.toBe(testObject.nested);

// But Dayjs instances should be the same reference (not cloned)
expect(clonedObject.date).toBe(testObject.date);
expect(clonedObject.nested.anotherDate).toBe(testObject.nested.anotherDate);

// And they should still work as Dayjs objects
expect(clonedObject.date.format('YYYY-MM-DD')).toEqual('2019-12-31');
expect(clonedObject.nested.anotherDate.format('YYYY-MM-DD')).toEqual('2020-01-01');
});

it('should handle custom complex objects with toJSON method', () => {
const complexObj = new ComplexDateClass('2019-12-31');
const testObject = {
name: 'Test',
complexDate: complexObj
};

const cloneService = new ClonerService();
const clonedObject = cloneService.deepClone(testObject);

// The object structure is cloned
expect(clonedObject).not.toBe(testObject);
expect(clonedObject.name).toEqual('Test');

// But the complex object should be the same reference
expect(clonedObject.complexDate).toBe(testObject.complexDate);

// And it should still have its methods
expect(clonedObject.complexDate.format).toBeDefined();
expect(clonedObject.complexDate.getTime()).toEqual(new Date('2019-12-31').getTime());
});

it('should clone plain objects deeply', () => {
const plainObject = {
name: 'Test',
nested: {
value: 123,
deep: {
items: [1, 2, 3]
}
}
};

const cloneService = new ClonerService();
const clonedObject = cloneService.deepClone(plainObject);

// Everything should be cloned
expect(clonedObject).not.toBe(plainObject);
expect(clonedObject.nested).not.toBe(plainObject.nested);
expect(clonedObject.nested.deep).not.toBe(plainObject.nested.deep);
expect(clonedObject.nested.deep.items).not.toBe(plainObject.nested.deep.items);

// But values should be equal
expect(clonedObject.name).toEqual('Test');
expect(clonedObject.nested.value).toEqual(123);
expect(clonedObject.nested.deep.items).toEqual([1, 2, 3]);
});

it('should handle mixed objects with dates and complex objects', () => {
const testObject = {
name: 'Test',
regularDate: new Date('2019-12-31'),
dayjsDate: dayjs('2020-01-01'),
nested: {
complexDate: new ComplexDateClass('2021-01-01')
}
};

const cloneService = new ClonerService();
const clonedObject = cloneService.deepClone(testObject);

// Regular dates should be cloned
expect(clonedObject.regularDate).not.toBe(testObject.regularDate);
expect(clonedObject.regularDate.getTime()).toEqual(testObject.regularDate.getTime());

// Dayjs objects should be same reference
expect(clonedObject.dayjsDate).toBe(testObject.dayjsDate);
expect(clonedObject.dayjsDate.format('YYYY-MM-DD')).toEqual('2020-01-01');

// Complex objects should be same reference
expect(clonedObject.nested.complexDate).toBe(testObject.nested.complexDate);
expect(clonedObject.nested.complexDate.format).toBeDefined();
});
});
55 changes: 54 additions & 1 deletion modules/observable-store/utilities/cloner.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ export class ClonerService {
return result;
}

// Check if this is a plain object or array that can be safely JSON-cloned
// For complex objects with custom prototypes (like Dayjs, Moment, etc.),
// return the original reference to avoid cloning issues
if (!this.isCloneable(value)) {
return value;
}

result = JSON.parse(JSON.stringify(value));
this.fixTypes(value, result);
return result;
Expand Down Expand Up @@ -63,7 +70,14 @@ export class ClonerService {
else if (originalValue == null) {
copy[key] = originalValue;
}
else {
else if (!this.isCloneable(originalValue)) {
// For complex objects with custom prototypes (Dayjs, Moment, etc.),
// use the original reference to avoid cloning issues
copy[key] = originalValue;
}
else if (typeof copy[key] === 'object' && copy[key] !== null) {
// Only call fixTypes if copy[key] is still an object
// (not converted to a string/primitive by JSON.stringify)
this.fixTypes(originalValue, copy[key]);
}
break;
Expand Down Expand Up @@ -102,4 +116,43 @@ export class ClonerService {
return new RegExp(regexpText.slice(1, slashIndex), regexpText.slice(slashIndex + 1));
}

private isCloneable(value: any): boolean {
// Arrays are always cloneable
if (value instanceof Array) {
return true;
}

// Check if it's a plain object (created by {} or new Object())
const proto = Object.getPrototypeOf(value);
if (proto === Object.prototype || proto === null) {
return true;
}

// If the object has a toJSON method, check if it would convert to a primitive
// This handles Dayjs, Moment, and other objects with custom JSON serialization
if (typeof value.toJSON === 'function') {
try {
const jsonResult = value.toJSON();
const jsonType = typeof jsonResult;
// If toJSON returns a primitive (string, number, etc.), it's not safely cloneable
if (jsonType !== 'object' || jsonResult === null) {
return false;
}
} catch (e) {
// If toJSON throws, treat as not cloneable
return false;
}
}

// Check if the prototype has methods beyond constructor
// Objects with methods are complex and shouldn't be cloned via JSON
const protoProps = Object.getOwnPropertyNames(proto);
if (protoProps.some(prop => prop !== 'constructor' && typeof value[prop] === 'function')) {
return false;
}

// Simple data classes (only properties, no methods) are cloneable
return true;
}

}