;
+
export type Plugins = {[key: string]: Plugin}|undefined;
export type Options = {
@@ -194,7 +210,7 @@ export class Core {
cancelCallbacks = new Set();
// set of forms currently hvaing their set() functions called
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- static changingForms = new Set>();
+ static changingForms = new Set();
// debounce ids and count numbers to identify when each id has debounced
debounceMap = new Map();
@@ -357,12 +373,13 @@ export class Core {
// call all change callbacks involved in this form and clear revert points
if(originator) {
+ const changedForms = Array.from(Core.changingForms.values());
+
// clear the changing forms set before calling callbacks
// so any calls to .set in callbacks start a new change
- const forms = Array.from(Core.changingForms.values());
this.finaliseChange();
- forms.forEach(form => form.callAllChangeCallbacks(internalMeta));
+ changedForms.forEach(form => form.callAllChangeCallbacks(internalMeta));
}
} catch(e) {
@@ -458,6 +475,9 @@ export class Core {
// e.g. when sync() deliberately adds empty history items
if(historyItem.do.value.length > 0) {
this.callAllDeriveCallbacks(internalMeta);
+
+ // look through historySyncGroups to find if any forms need blank history items added
+ _chunkRegistry.executeHistorySync?.(Core.changingForms);
}
}, internalMeta);
};
@@ -640,6 +660,9 @@ export class Core {
// call derive callbacks
this.callAllDeriveCallbacks(internalMeta);
+
+ // look through historySyncGroups to find if any forms need their history updated
+ _chunkRegistry.executeHistorySync?.(Core.changingForms, offset);
}, internalMeta);
this.internalState.going = false;
diff --git a/packages/dendriform/src/errors.ts b/packages/dendriform/src/errors.ts
index e74fde9..0eb4fdf 100644
--- a/packages/dendriform/src/errors.ts
+++ b/packages/dendriform/src/errors.ts
@@ -10,7 +10,9 @@ const errors = {
6: (msg: string) => `onDerive() callback must not throw errors on first call. Threw: ${msg}`,
7: `Cannot call .set() on an element of an es6 Set`,
8: `Plugin must be passed into a Dendriform instance before this operation can be called`,
- 9: `Cannot call .set() or .go() on a readonly form`
+ 9: `Cannot call .set() or .go() on a readonly form`,
+ 10: `All syncHistory() forms must each have a matching non-zero number of history items configured, e.g. {history: 10}`,
+ 11: `syncHistory() can only be applied to forms that have not had any changes made yet`
} as const;
export type ErrorKey = keyof typeof errors;
diff --git a/packages/dendriform/src/index.ts b/packages/dendriform/src/index.ts
index 83bd6ca..b94757f 100644
--- a/packages/dendriform/src/index.ts
+++ b/packages/dendriform/src/index.ts
@@ -7,6 +7,7 @@ export * from './useInput';
export * from './useCheckbox';
export * from './useProps';
export * from './sync';
+export * from './syncHistory';
export * from './diff';
export * from './LazyDerive';
export * from './Plugin';
diff --git a/packages/dendriform/src/sync.ts b/packages/dendriform/src/sync.ts
index 5ac1fc5..2e7bf0c 100644
--- a/packages/dendriform/src/sync.ts
+++ b/packages/dendriform/src/sync.ts
@@ -9,6 +9,8 @@ export const sync = (
derive?: DeriveCallback
): (() => void) => {
+ console.warn('sync() is deprecated and will be removed in the next major version. Use historySync() instead.');
+
if(masterForm.core.historyLimit !== slaveForm.core.historyLimit) {
die(5);
}
diff --git a/packages/dendriform/src/syncHistory.ts b/packages/dendriform/src/syncHistory.ts
new file mode 100644
index 0000000..bb66bf2
--- /dev/null
+++ b/packages/dendriform/src/syncHistory.ts
@@ -0,0 +1,81 @@
+import {useEffect} from 'react';
+import {noChange} from './producePatches';
+import {die} from './errors';
+import {_chunkRegistry} from './Dendriform';
+import type {Dendriform, AnyCore} from './Dendriform';
+
+//
+// history sync
+//
+
+// state
+// set of forms whose history is to be grouped together
+const historySyncGroups = new Map();
+let historySyncGroupsNextKey = 0;
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+export const historySync = (...forms: Dendriform[]): void => {
+ const cores = forms.map(form => form.core);
+
+ // validate args
+ if(cores.length === 0
+ || cores.some(core => core.historyLimit !== cores[0].historyLimit || core.historyLimit < 1)
+ ) {
+ die(10);
+ }
+
+ if(cores.some(core => core.state.historyIndex > 0)) {
+ die(11);
+ }
+
+ // create key
+ const thisKey = ++historySyncGroupsNextKey;
+
+ // find existing keys in common with new ones
+ const commonKeys = new Set();
+ cores.forEach(core => {
+ const existing = historySyncGroups.get(core);
+ if(existing) {
+ commonKeys.add(existing);
+ }
+ });
+
+ // update common keys to match newest key
+ Array.from(historySyncGroups.entries()).forEach(([core, key]) => {
+ if(commonKeys.has(key)) {
+ historySyncGroups.set(core, thisKey);
+ }
+ });
+
+ // add passed in keys
+ cores.forEach(core => {
+ historySyncGroups.set(core, thisKey);
+ });
+}
+
+export const useHistorySync = (...forms: Dendriform[]): void => {
+ useEffect(() => {
+ historySync(...forms);
+ }, []);
+};
+
+// look through historySyncGroups to find if any forms need blank history items added
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+_chunkRegistry.executeHistorySync = (changedFormSet: Set, offset: number = 0): void => {
+
+ const changedSyncGroups = new Set(
+ Array.from(changedFormSet)
+ .map(core => historySyncGroups.get(core) as number)
+ .filter(key => !!key)
+ );
+
+ historySyncGroups.forEach((key, core) => {
+ if(!changedFormSet.has(core) && changedSyncGroups.has(key)) {
+ if(offset) {
+ core.go(offset);
+ } else {
+ core.set('0', noChange, {});
+ }
+ }
+ });
+};
\ No newline at end of file
diff --git a/packages/dendriform/test/Dendriform.test.tsx b/packages/dendriform/test/Dendriform.test.tsx
index 1bd83cf..c0b8b87 100644
--- a/packages/dendriform/test/Dendriform.test.tsx
+++ b/packages/dendriform/test/Dendriform.test.tsx
@@ -1,4 +1,4 @@
-import {useDendriform, Dendriform, noChange, sync, useSync, immerable, cancel, Plugin} from '../src/index';
+import {useDendriform, Dendriform, historySync, useHistorySync, noChange, sync, useSync, immerable, cancel, Plugin} from '../src/index';
import {renderHook, act} from '@testing-library/react-hooks';
import {BASIC, OBJECT, ARRAY} from 'dendriform-immer-patch-optimiser';
@@ -533,22 +533,124 @@ describe(`Dendriform`, () => {
const form = new Dendriform(123, {history: 100});
form.set(456);
- // form.core.flush();
form.set(noChange);
- // form.core.flush();
expect(form.value).toBe(456);
form.undo();
- // form.core.flush();
expect(form.value).toBe(456);
form.undo();
- // form.core.flush();
expect(form.value).toBe(123);
});
+
+ describe(`historySync`, () => {
+
+ test(`should allow forms to sync`, () => {
+ const formA = new Dendriform(123, {history: 100});
+ const formB = new Dendriform(123, {history: 100});
+ const formC = new Dendriform(123, {history: 100});
+
+ historySync(formA, formB);
+
+ formA.set(456);
+
+ expect(formA.core.state.historyIndex).toBe(1);
+ expect(formA.value).toBe(456);
+ expect(formB.core.state.historyIndex).toBe(1);
+ expect(formB.value).toBe(123);
+ expect(formC.core.state.historyIndex).toBe(0);
+ expect(formC.value).toBe(123);
+
+ // undo with one form, expect other form to also undo
+ formA.undo();
+
+ expect(formA.core.state.historyIndex).toBe(0);
+ expect(formA.value).toBe(123);
+ expect(formB.core.state.historyIndex).toBe(0);
+ expect(formB.value).toBe(123);
+ expect(formC.core.state.historyIndex).toBe(0);
+ expect(formC.value).toBe(123);
+
+ // redo with other form, expect other form to also redo
+ formB.redo();
+
+ expect(formA.core.state.historyIndex).toBe(1);
+ expect(formA.value).toBe(456);
+ expect(formB.core.state.historyIndex).toBe(1);
+ expect(formB.value).toBe(123);
+ expect(formC.core.state.historyIndex).toBe(0);
+ expect(formC.value).toBe(123);
+ });
+
+ test(`should join sync groups together`, () => {
+ const formA = new Dendriform(123, {history: 100});
+ const formB = new Dendriform(123, {history: 100});
+ const formC = new Dendriform(123, {history: 100});
+ const formD = new Dendriform(123, {history: 100});
+ const formE = new Dendriform(123, {history: 100});
+ const formF = new Dendriform(123, {history: 100});
+
+ historySync(formA, formB);
+ historySync(formC, formD);
+ historySync(formC, formB);
+ historySync(formE, formF);
+
+ formD.set(456);
+
+ expect(formA.core.state.historyIndex).toBe(1);
+ expect(formB.core.state.historyIndex).toBe(1);
+ expect(formC.core.state.historyIndex).toBe(1);
+ expect(formD.core.state.historyIndex).toBe(1);
+ expect(formE.core.state.historyIndex).toBe(0);
+ expect(formF.core.state.historyIndex).toBe(0);
+ });
+
+ test(`should error if passed mismatched history sizes`, () => {
+ const formA = new Dendriform(123, {history: 100});
+ const formB = new Dendriform(123, {history: 100});
+ const formC = new Dendriform(123, {history: 200});
+
+ expect(() => historySync(formA, formB, formC)).toThrow('[Dendriform] All syncHistory() forms must each have a matching non-zero number of history items configured, e.g. {history: 10}');
+ });
+
+ test(`should error if passed zero history sizes`, () => {
+ const formA = new Dendriform(123, {history: 100});
+ const formB = new Dendriform(123);
+
+ expect(() => historySync(formA, formB)).toThrow('[Dendriform] All syncHistory() forms must each have a matching non-zero number of history items configured, e.g. {history: 10}');
+ });
+
+ test(`should error if any form has already changed`, () => {
+ const formA = new Dendriform(123, {history: 100});
+ const formB = new Dendriform(123, {history: 100});
+ const formC = new Dendriform(123, {history: 100});
+ formC.set(456);
+
+ expect(() => historySync(formA, formB, formC)).toThrow('[Dendriform] syncHistory() can only be applied to forms that have not had any changes made yet');
+ });
+
+ test(`should useHistorySync`, () => {
+
+ const hookA = renderHook(() => useDendriform(() => 'hi', {history: 10}));
+ const hookB = renderHook(() => useDendriform(() => 0, {history: 10}));
+
+ const formA = hookA.result.current;
+ const formB = hookB.result.current;
+
+ renderHook(() => {
+ useHistorySync(formA, formB);
+ });
+
+ act(() => {
+ formA.set('hello');
+ });
+
+ expect(formB.core.state.historyIndex).toBe(1);
+ });
+ });
});
describe('useDendriform() and prop changes', () => {
@@ -2943,7 +3045,7 @@ describe(`Dendriform`, () => {
});
});
- test(`should remove nodes as parent changes type`, () => {
+ test(`should remove nodes as parent changes type`, () => {
const form = new Dendriform({
foo: {
bar: 123,