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}
; + })} + + +
; +} +``` + #### 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()})} + + + + ; +} + +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()})} + + + +
; +} +`; + // // 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}
; + })} + + +
; +} +``` + #### 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!'); + }); + +});