diff --git a/README.md b/README.md index 133b118..b6a1811 100644 --- a/README.md +++ b/README.md @@ -213,7 +213,7 @@ function Counter() { } ``` -This subscription will not cause any re-renders. +This subscription will not cause any re-renders. By default, the first call will happen during the component initialization, and you can pass a second options object to alter this behaviour. You can either set `{ skipFirstCall: true }` to completely skip it, or you can specify to run it when the component is mounted in DOM: `{ callOnMount: true }`. ### Combining different states diff --git a/integration-tests/create-state.test.ts b/integration-tests/create-state.test.ts index a67ccc9..5a9d480 100644 --- a/integration-tests/create-state.test.ts +++ b/integration-tests/create-state.test.ts @@ -154,6 +154,89 @@ describe("createState", () => { expect(onUnmountCheck).not.toHaveBeenCalled(); }); + it("supports custom subscriptions with state.trackValue with skipFirstCall option", async () => { + const user = userEvent.setup(); + const spyFn = jest.fn(); + function StateComponent() { + const valueState = createState(0); + valueState.trackValue((value) => spyFn(value), { skipFirstCall: true }); + + return createElement("div", { + children: [ + createElement("button", { + "data-testid": "button", + onClick: () => { + valueState.setValue((currentValue) => currentValue + 1); + }, + }), + valueState.useValue((value) => + createElement("div", { children: [`current value is ${value}`] }) + ), + ], + }); + } + + cleanup = attachComponent({ + htmlElement: document.body, + component: createElement(StateComponent), + }); + + expect(spyFn).toHaveBeenCalledTimes(0); + + const btn = screen.getByTestId("button"); + await user.click(btn); + expect(spyFn).toHaveBeenCalledTimes(1); + expect(spyFn).toHaveBeenLastCalledWith(1); + await user.click(btn); + expect(spyFn).toHaveBeenCalledTimes(2); + expect(spyFn).toHaveBeenLastCalledWith(2); + }); + + it("supports custom subscriptions with state.trackValue with callOnMount option", async () => { + const user = userEvent.setup(); + const spyFn = jest.fn(); + function StateComponent() { + const valueState = createState(0); + valueState.trackValue((value) => spyFn(value), { callOnMount: true }); + + return createElement("div", { + children: [ + createElement("button", { + "data-testid": "button", + onClick: () => { + valueState.setValue((currentValue) => currentValue + 1); + }, + }), + valueState.useValue((value) => + createElement("div", { children: [`current value is ${value}`] }) + ), + ], + }); + } + + cleanup = attachComponent({ + htmlElement: document.body, + component: createElement(StateComponent), + }); + + expect(spyFn).toHaveBeenCalledTimes(0); + + // wait until the component is mounted in DOM + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + expect(spyFn).toHaveBeenCalledTimes(1); + expect(spyFn).toHaveBeenLastCalledWith(0); + + const btn = screen.getByTestId("button"); + await user.click(btn); + expect(spyFn).toHaveBeenCalledTimes(2); + expect(spyFn).toHaveBeenLastCalledWith(1); + await user.click(btn); + expect(spyFn).toHaveBeenCalledTimes(3); + expect(spyFn).toHaveBeenLastCalledWith(2); + }); + // test to make sure that `useValueSelector` is correctly called only when // the selector function returns a different result test("support selector functions correctly with useValueSelector", async () => { diff --git a/src/hooks/create-state.ts b/src/hooks/create-state.ts index 2f10925..fa5e241 100644 --- a/src/hooks/create-state.ts +++ b/src/hooks/create-state.ts @@ -1,5 +1,5 @@ import { getComponentVelesNode, identity } from "../utils"; -import { onUnmount } from "./lifecycle"; +import { onUnmount, onMount } from "./lifecycle"; import { createElement } from "../create-element/create-element"; import type { VelesElement, VelesComponent } from "../types"; @@ -10,7 +10,10 @@ type AttributeHelper = { }; export type State = { - trackValue(cb: (value: ValueType) => void | Function): void; + trackValue( + cb: (value: ValueType) => void | Function, + options?: { callOnMount?: boolean; skipFirstCall?: boolean } + ): void; useValue( cb: (value: ValueType) => VelesElement | VelesComponent, comparator?: (value1: ValueType, value2: ValueType) => boolean @@ -87,11 +90,19 @@ function createState( const result: State = { // supposed to be used at the component level // TODO: add a version of trackValueSelector - trackValue: (cb) => { + trackValue: (cb, options = {}) => { trackingEffects.push(cb); - // trigger the callback first time - // maybe provide an option to skip it first time? - cb(value); + if (!options.skipFirstCall) { + // trigger the callback first time + // execute the first callback when the component is mounted + if (options.callOnMount) { + onMount(() => { + cb(value); + }); + } else { + cb(value); + } + } // track value is attached at the component level onUnmount(() => { trackingEffects = trackingEffects.filter(