diff --git a/README.md b/README.md
index 2cba75e..42a2db0 100644
--- a/README.md
+++ b/README.md
@@ -490,7 +490,7 @@ function MyComponent(props) {
return
{form.render('count', form => {
- const [count] = form.useValue();
+ const count = form.useValue();
return
Count: {count}
;
})}
@@ -499,6 +499,27 @@ function MyComponent(props) {
}
```
+The `.set()` function can also be debounced by passing a number of milliseconds as the second argument to `.set()`.
+
+```js
+function MyComponent(props) {
+ const form = useDendriform(0);
+
+ const countUpDebounced = useCallback(() => {
+ form.set(count => count + 1, 100);
+ }, []);
+
+ return
+ {form.render(form => {
+ const count = form.useValue();
+ return
Count: {count}
;
+ })}
+
+
Count up
+
;
+}
+```
+
#### Buffering
To call it multiple times in a row, use `buffer()` to begin buffering changes and `done()` to apply the changes. These will affect the entire form including all branches, so `form.buffer()` has the same effect as `form.branch('example').buffer()`.
@@ -671,6 +692,28 @@ function MyComponent(props) {
[Demo](http://dendriform.xyz#inputs)
+You may also have form input components of your own whose `onChange` functions are called with the new value rather than a change event. The `useProps` hook can be spread onto these elements in a similar way to the `useInput` hook. These also support debouncing.
+
+```js
+import {useDendriform, useProps} from 'dendriform';
+
+function MyComponent(props) {
+
+ const form = useDendriform([]);
+
+ return
;
+};
+```
+
+This is equivalent to doing the following:
+
+```js
+return
form.set(value)}
+/>;
+```
+
### Subscribing to changes
You can subscribe to changes using `.onChange`, or by using the `.useChange()` hook if you're inside a React component's render method.
diff --git a/packages/dendriform-demo/components/Demos.tsx b/packages/dendriform-demo/components/Demos.tsx
index 663729f..7a8e168 100644
--- a/packages/dendriform-demo/components/Demos.tsx
+++ b/packages/dendriform-demo/components/Demos.tsx
@@ -450,6 +450,58 @@ function MyComponent(props) {
}
`;
+//
+// setting data with debouncing
+//
+
+function SettingDataDebounce(): React.ReactElement {
+ const form = useDendriform({
+ a: 0,
+ b: 0
+ });
+
+ const changeA = useCallback(() => {
+ form.branch('a').set(Math.floor(Math.random() * 1000), 300);
+ }, []);
+
+ const changeB = useCallback(() => {
+ form.branch('b').set(Math.floor(Math.random() * 1000), 300);
+ }, []);
+
+ return
+ {form.render('a', form => {form.useValue()} )}
+ {form.render('b', form => {form.useValue()} )}
+
+ change a with 300ms debounce
+ change b with 300ms debounce
+ ;
+}
+
+const SettingDataDebounceCode = `
+function MyComponent(props) {
+ const form = useDendriform({
+ a: 0,
+ b: 0
+ });
+
+ const changeA = useCallback(() => {
+ form.branch('a').set(Math.floor(Math.random() * 1000), 300);
+ }, []);
+
+ const changeB = useCallback(() => {
+ form.branch('b').set(Math.floor(Math.random() * 1000), 300);
+ }, []);
+
+ return
+ {form.render('a', form => a: {form.useValue()}
)}
+ {form.render('b', form => b: {form.useValue()}
)}
+
+ change a with 300ms debounce
+ change b with 300ms debounce
+
;
+}
+`;
+
//
// es6 classes
//
@@ -1860,6 +1912,14 @@ const DEMOS: DemoObject[] = [
anchor: 'buffer',
more: 'setting-data'
},
+ {
+ title: 'Setting data with debouncing',
+ Demo: SettingDataDebounce,
+ code: SettingDataDebounceCode,
+ description: `This demonstrates how set() calls can be debounced. Click the buttons below rapidly and watch how the value updates more slowly.`,
+ anchor: 'debounce',
+ more: 'debounce'
+ },
{
title: 'ES6 classes',
Demo: ES6Classes,
diff --git a/packages/dendriform/.size-limit.js b/packages/dendriform/.size-limit.js
index ae03b71..7d11075 100644
--- a/packages/dendriform/.size-limit.js
+++ b/packages/dendriform/.size-limit.js
@@ -2,7 +2,7 @@ module.exports = [
{
name: 'everything combined',
path: "dist/dendriform.esm.js",
- limit: "8.4 KB",
+ limit: "8.6 KB",
ignore: ['react', 'react-dom']
},
{
@@ -16,7 +16,7 @@ module.exports = [
name: 'Dendriform',
path: "dist/dendriform.esm.js",
import: "{ Dendriform }",
- limit: "7.6 KB",
+ limit: "7.8 KB",
ignore: ['react', 'react-dom']
},
{
diff --git a/packages/dendriform/README.md b/packages/dendriform/README.md
index 2cba75e..42a2db0 100644
--- a/packages/dendriform/README.md
+++ b/packages/dendriform/README.md
@@ -490,7 +490,7 @@ function MyComponent(props) {
return
{form.render('count', form => {
- const [count] = form.useValue();
+ const count = form.useValue();
return
Count: {count}
;
})}
@@ -499,6 +499,27 @@ function MyComponent(props) {
}
```
+The `.set()` function can also be debounced by passing a number of milliseconds as the second argument to `.set()`.
+
+```js
+function MyComponent(props) {
+ const form = useDendriform(0);
+
+ const countUpDebounced = useCallback(() => {
+ form.set(count => count + 1, 100);
+ }, []);
+
+ return
+ {form.render(form => {
+ const count = form.useValue();
+ return
Count: {count}
;
+ })}
+
+
Count up
+
;
+}
+```
+
#### Buffering
To call it multiple times in a row, use `buffer()` to begin buffering changes and `done()` to apply the changes. These will affect the entire form including all branches, so `form.buffer()` has the same effect as `form.branch('example').buffer()`.
@@ -671,6 +692,28 @@ function MyComponent(props) {
[Demo](http://dendriform.xyz#inputs)
+You may also have form input components of your own whose `onChange` functions are called with the new value rather than a change event. The `useProps` hook can be spread onto these elements in a similar way to the `useInput` hook. These also support debouncing.
+
+```js
+import {useDendriform, useProps} from 'dendriform';
+
+function MyComponent(props) {
+
+ const form = useDendriform([]);
+
+ return
;
+};
+```
+
+This is equivalent to doing the following:
+
+```js
+return
form.set(value)}
+/>;
+```
+
### Subscribing to changes
You can subscribe to changes using `.onChange`, or by using the `.useChange()` hook if you're inside a React component's render method.
diff --git a/packages/dendriform/src/Dendriform.tsx b/packages/dendriform/src/Dendriform.tsx
index 255835e..fa99bdd 100644
--- a/packages/dendriform/src/Dendriform.tsx
+++ b/packages/dendriform/src/Dendriform.tsx
@@ -171,6 +171,19 @@ class Core {
// setting data
//
+ debounceMap = new Map();
+
+ setWithDebounce = (id: string, toProduce: unknown, debounce = 0): void => {
+ if(debounce === 0) {
+ this.set(id, toProduce);
+ return;
+ }
+
+ const countAtCall = (this.debounceMap.get(id) ?? 0) + 1;
+ this.debounceMap.set(id, countAtCall);
+ setTimeout(() => countAtCall === this.debounceMap.get(id) && this.set(id, toProduce), debounce);
+ };
+
// if setBuffer exists, then new changes will be merged onto it
// if not, a new change will push a new history item
bufferingChanges = false;
@@ -518,14 +531,14 @@ export class Dendriform {
return this.core.historyState;
}
- set = (toProduce: ToProduce): void => {
- this.core.set(this.id, toProduce);
+ set = (toProduce: ToProduce, debounce = 0): void => {
+ this.core.setWithDebounce(this.id, toProduce, debounce);
};
- setParent = (childToProduce: ChildToProduce): void => {
+ setParent = (childToProduce: ChildToProduce, debounce = 0): void => {
const basePath = this.core.getPathOrError(this.id);
const parent = this.core.getFormAt(basePath.slice(0,-1));
- this.core.set(parent.id, childToProduce(basePath[basePath.length - 1]));
+ this.core.setWithDebounce(parent.id, childToProduce(basePath[basePath.length - 1]), debounce);
};
onChange(callback: ChangeCallback, changeType: ChangeTypeIndex): (() => void);
diff --git a/packages/dendriform/src/Nodes.ts b/packages/dendriform/src/Nodes.ts
index 582ae17..aa1fae0 100644
--- a/packages/dendriform/src/Nodes.ts
+++ b/packages/dendriform/src/Nodes.ts
@@ -153,6 +153,22 @@ export const updateNode = (nodes: Nodes, id: string, value: unknown): void => {
};
};
+export const updateArrayNodeLength = (nodes: Nodes, id: string, length: number): void => {
+ const node = get(nodes, id) as NodeAny|undefined;
+ if(!node || node.type !== ARRAY) return;
+
+ const child = node.child as string[];
+ const newChild = child.slice(0, length);
+ child.slice(length).forEach(id => {
+ removeNode(nodes, id);
+ });
+
+ nodes[id] = {
+ ...node,
+ child: newChild
+ };
+};
+
export const produceNodePatches = (
nodes: Nodes,
newNodeCreator: NewNodeCreator,
@@ -186,6 +202,13 @@ export const produceNodePatches = (
op = 'replace';
}
+ // if an array is changed by altering length
+ // change the patch to do the same thing via a replace
+ if(parentNode.type === ARRAY && path[path.length - 1] === 'length') {
+ updateArrayNodeLength(draft, parentNode.id, value as number);
+ return;
+ }
+
// depending on type, make changes to the child node
// and to the parent node's child
if(op === 'add') {
@@ -233,9 +256,6 @@ export const produceNodePatches = (
applyPatches(draft, patchesForNodes);
});
-
- //console.log('node patches', result[1]);
-
(result[1] as DendriformPatch[]).forEach(patch => patch.namespace = 'nodes');
(result[2] as DendriformPatch[]).forEach(patch => patch.namespace = 'nodes');
return result as readonly [Nodes, DendriformPatch[], DendriformPatch[]];
diff --git a/packages/dendriform/src/index.ts b/packages/dendriform/src/index.ts
index cc5d23f..5bb9b89 100644
--- a/packages/dendriform/src/index.ts
+++ b/packages/dendriform/src/index.ts
@@ -5,5 +5,6 @@ export * from './Nodes';
export * from './array';
export * from './useInput';
export * from './useCheckbox';
+export * from './useProps';
export * from './sync';
export {immerable} from 'immer';
diff --git a/packages/dendriform/src/sync.ts b/packages/dendriform/src/sync.ts
index e80be37..e881ac9 100644
--- a/packages/dendriform/src/sync.ts
+++ b/packages/dendriform/src/sync.ts
@@ -1,5 +1,5 @@
-import {noChange} from './index';
import type {Dendriform, DeriveCallback, DeriveCallbackDetails} from './index';
+import {noChange} from './producePatches';
import {die} from './errors';
import {useEffect} from 'react';
diff --git a/packages/dendriform/src/useInput.ts b/packages/dendriform/src/useInput.ts
index 3d62d41..91d1188 100644
--- a/packages/dendriform/src/useInput.ts
+++ b/packages/dendriform/src/useInput.ts
@@ -1,16 +1,6 @@
-import {useState, useCallback, useRef} from 'react';
+import {useState, useCallback} from 'react';
import type {Dendriform} from './Dendriform';
-type DebouncedCallbackReturn = (arg: A) => void;
-
-export const useDebounceCallback = (debounce: number, callback: (arg: A) => void, deps: unknown[]): DebouncedCallbackReturn => {
- const count = useRef(0);
- return useCallback(arg => {
- const countAtCall = ++count.current;
- setTimeout(() => countAtCall === count.current && callback(arg), debounce);
- }, deps);
-};
-
type UseInputResult = {
value: string,
onChange: (event: React.ChangeEvent) => void
@@ -26,14 +16,10 @@ export const useInput = (form: Dendriform {
- form.set(value);
- }, []);
-
const onChange = useCallback(event => {
const newValue = event.target.value;
setLocalValue(newValue);
- onChangeDebounced(newValue);
+ form.set(newValue, debounce);
}, []);
return {
diff --git a/packages/dendriform/src/useProps.ts b/packages/dendriform/src/useProps.ts
new file mode 100644
index 0000000..7e9670e
--- /dev/null
+++ b/packages/dendriform/src/useProps.ts
@@ -0,0 +1,28 @@
+import {useState, useCallback} from 'react';
+import type {Dendriform} from './Dendriform';
+
+type UsePropsResult = {
+ value: V,
+ onChange: (newValue: V) => void
+};
+
+export const useProps = (form: Dendriform, debounce = 0): UsePropsResult => {
+ const formValue = form.useValue();
+ const [lastFormValue, setLastFormValue] = useState(formValue);
+ const [localValue, setLocalValue] = useState(formValue);
+
+ if(formValue !== lastFormValue) {
+ setLastFormValue(formValue);
+ setLocalValue(formValue);
+ }
+
+ const onChange = useCallback((newValue: V) => {
+ setLocalValue(newValue);
+ form.set(newValue, debounce);
+ }, []);
+
+ return {
+ value: localValue,
+ onChange
+ };
+};
diff --git a/packages/dendriform/test/Dendriform.test.tsx b/packages/dendriform/test/Dendriform.test.tsx
index 228769e..d3e89f6 100644
--- a/packages/dendriform/test/Dendriform.test.tsx
+++ b/packages/dendriform/test/Dendriform.test.tsx
@@ -6,6 +6,8 @@ import Enzyme, {mount} from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import {enableMapSet} from 'immer';
+jest.useFakeTimers();
+
Enzyme.configure({
adapter: new Adapter()
});
@@ -69,6 +71,22 @@ describe(`Dendriform`, () => {
expect(form.value).toBe(6);
});
+
+ test(`should set value with debounce`, () => {
+ const form = new Dendriform(123);
+
+ form.set(456, 100);
+ jest.advanceTimersByTime(80);
+ expect(form.value).toBe(123);
+
+ form.set(789, 100);
+ jest.advanceTimersByTime(80);
+ expect(form.value).toBe(123);
+
+ jest.advanceTimersByTime(800);
+
+ expect(form.value).toBe(789);
+ });
});
describe('useDendriform() and .useValue()', () => {
diff --git a/packages/dendriform/test/Nodes.test.ts b/packages/dendriform/test/Nodes.test.ts
index 6119c83..1eeebcb 100644
--- a/packages/dendriform/test/Nodes.test.ts
+++ b/packages/dendriform/test/Nodes.test.ts
@@ -4,8 +4,8 @@ import {BASIC, OBJECT, ARRAY, MAP, applyPatches} from 'dendriform-immer-patch-op
import type {Path} from 'dendriform-immer-patch-optimiser';
import produce from 'immer';
-const createNodesFrom = (value: unknown): [Nodes, NewNodeCreator] => {
- const countRef = {current: 0};
+const createNodesFrom = (value: unknown, current: number = 0): [Nodes, NewNodeCreator] => {
+ const countRef = {current};
const newNodeCreator = newNode(countRef);
// use immer to add this, because immer freezes things and the tests must cope with that
@@ -760,6 +760,19 @@ describe(`Nodes`, () => {
expect(newNodes).toEqual(nodesBefore);
});
+ test(`#47 incorrect nodes bug`, () => {
+ const value = JSON.parse(`{"name":"Wappy","address":{"street":"Pump St"},"pets":[{"name":"Spike"},{"name":"Spoke"}]}`);
+ const [,newNodeCreator] = createNodesFrom(value, 8);
+
+ const nodesBefore = JSON.parse(`{"0":{"type":1,"child":{"name":"1","address":"2","pets":"4"},"parentId":"","id":"0"},"1":{"type":0,"parentId":"0","id":"1"},"2":{"type":1,"child":{"street":"3"},"parentId":"0","id":"2"},"3":{"type":0,"parentId":"2","id":"3"},"4":{"type":2,"child":["5","6"],"parentId":"0","id":"4"},"5":{"type":1,"child":{"name":"7"},"parentId":"4","id":"5"},"6":{"type":1,"child":{"name":"8"},"parentId":"4","id":"6"},"7":{"type":0,"parentId":"5","id":"7"},"8":{"type":0,"parentId":"6","id":"8"}}`);
+ const expectedNodes = JSON.parse(`{"0":{"type":1,"child":{"name":"1","address":"2","pets":"4"},"parentId":"","id":"0"},"1":{"type":0,"parentId":"0","id":"1"},"2":{"type":1,"child":{"street":"3"},"parentId":"0","id":"2"},"3":{"type":0,"parentId":"2","id":"3"},"4":{"type":2,"child":["5"],"parentId":"0","id":"4"},"5":{"type":1,"child":{"name":"7"},"parentId":"4","id":"5"},"7":{"type":0,"parentId":"5","id":"7"}}`);
+ const patches = JSON.parse(`[{"op":"replace","path":["pets","length"],"value":1}]`);
+
+ const [newNodes] = produceNodePatches(nodesBefore, newNodeCreator, value, patches);
+
+ expect(newNodes).toEqual(expectedNodes);
+ });
+
test(`of type "move"`, () => {
const value = ['a','b','c'];
const [nodes, newNodeCreator] = createNodesFrom(value);
diff --git a/packages/dendriform/test/useInput.test.ts b/packages/dendriform/test/useInput.test.ts
index d6bcf85..bc960a1 100644
--- a/packages/dendriform/test/useInput.test.ts
+++ b/packages/dendriform/test/useInput.test.ts
@@ -26,17 +26,6 @@ describe(`useInput`, () => {
// the same callback should be provided even after hook update
expect(result.current.onChange).toBe(firstCallback);
-
- // useInput's state should have changed,
- // but the change should not have propagated anywhere yet
- expect(result.current.value).toBe('hello');
- expect(form.value).toBe('hi');
-
- // flush buffer, allow setTimeouts to run, and re-test
- act(() => {
- jest.advanceTimersByTime(10);
- });
-
expect(result.current.value).toBe('hello');
expect(form.value).toBe('hello');
diff --git a/packages/dendriform/test/useProps.test.ts b/packages/dendriform/test/useProps.test.ts
new file mode 100644
index 0000000..0a98f7d
--- /dev/null
+++ b/packages/dendriform/test/useProps.test.ts
@@ -0,0 +1,63 @@
+import {useProps, Dendriform} from '../src/index';
+import {renderHook, act} from '@testing-library/react-hooks';
+
+jest.useFakeTimers();
+
+describe(`useProps`, () => {
+
+ test(`should provide value and onChange`, () => {
+ const form = new Dendriform('hi');
+
+ const {result} = renderHook(() => useProps(form));
+ expect(result.current.value).toBe('hi');
+
+ const firstCallback = result.current.onChange;
+
+ act(() => {
+ result.current.onChange('hello');
+ });
+
+ // the same callback should be provided even after hook update
+ expect(result.current.onChange).toBe(firstCallback);
+ expect(result.current.value).toBe('hello');
+ expect(form.value).toBe('hello');
+
+ });
+
+ test(`should debounce`, () => {
+ const form = new Dendriform('hi');
+
+ const {result} = renderHook(() => useProps(form, 100));
+ expect(result.current.value).toBe('hi');
+
+ act(() => {
+ result.current.onChange('hello');
+ jest.advanceTimersByTime(10);
+ });
+
+ // useProps's state should have changed,
+ // but the change should not have propagated anywhere yet
+ expect(result.current.value).toBe('hello');
+ expect(form.value).toBe('hi');
+
+ // change the value again
+ act(() => {
+ result.current.onChange('hello!');
+ jest.advanceTimersByTime(10);
+ });
+
+ // useProps's state should have changed,
+ // but the change should not have propagated anywhere yet
+ expect(result.current.value).toBe('hello!');
+ expect(form.value).toBe('hi');
+
+ // allow debounce period to lapse, should now have propagated
+ act(() => {
+ jest.advanceTimersByTime(110);
+ });
+
+ expect(result.current.value).toBe('hello!');
+ expect(form.value).toBe('hello!');
+ });
+
+});