diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml new file mode 100644 index 0000000..a90b336 --- /dev/null +++ b/.github/workflows/nodejs.yml @@ -0,0 +1,48 @@ +# CI workflow — builds, lints, and tests the project. +# +# Runs on both pull requests (to validate changes before merge) and pushes +# to master (to verify the integrated result). This ensures that the code +# on master is always in a known-good state before the Publish workflow +# attempts to release it. + +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + build: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [20.x] + + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Install yarn + run: npm install -g yarn + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Build + run: yarn build + + - name: Lint + run: yarn lint + + - name: Test + run: yarn test diff --git a/packages/yavl/src/validate/updateChangedAnnotation.ts b/packages/yavl/src/validate/updateChangedAnnotation.ts index eaeddb2..5de88dc 100644 --- a/packages/yavl/src/validate/updateChangedAnnotation.ts +++ b/packages/yavl/src/validate/updateChangedAnnotation.ts @@ -50,7 +50,7 @@ const processComputedValueAnnotation = ( const previousValue = hasPath(path, processingContext.data) ? getPath(path, processingContext.data) : noValue; // update processingContext data if the value has changed - if (value !== noValue && value !== previousValue) { + if (value !== noValue && !deepEqual(value, previousValue)) { processingContext.data = setPath(path, value, processingContext.data); } }; diff --git a/packages/yavl/tests/computedValues.spec.ts b/packages/yavl/tests/computedValues.spec.ts index 5ebd411..1e52b09 100644 --- a/packages/yavl/tests/computedValues.spec.ts +++ b/packages/yavl/tests/computedValues.spec.ts @@ -878,6 +878,158 @@ describe('computed values', () => { }); }); + describe('with computed value returning structurally equal object', () => { + it('should not cause unnecessary cascading passes when computed returns a new object with same structure', () => { + type TestModel = { + input: string; + computed?: { label: string }; + }; + + let computeCallCount = 0; + + const testModel = model((root, builder) => [ + builder.withFields(root, ['input', 'computed'], ({ input, computed }) => [ + builder.value( + computed, + builder.compute(input, () => { + computeCallCount++; + return { label: 'static' }; + }), + ), + ]), + ]); + + testIncrementalValidate(testModel, { input: 'a' }); + expect(getModelData(validationContext!)).toEqual({ + input: 'a', + computed: { label: 'static' }, + }); + + computeCallCount = 0; + testIncrementalValidate(testModel, { input: 'b' }); + + expect(computeCallCount).toBe(1); + expect(getModelData(validationContext!)).toEqual({ + input: 'b', + computed: { label: 'static' }, + }); + }); + + it('should still update when computed value actually changes', () => { + type TestModel = { + input: string; + computed?: { derived: string }; + }; + + const testModel = model((root, builder) => [ + builder.withFields(root, ['input', 'computed'], ({ input, computed }) => [ + builder.value( + computed, + builder.compute(input, input => ({ derived: input.toUpperCase() })), + ), + ]), + ]); + + testIncrementalValidate(testModel, { input: 'hello' }); + expect(getModelData(validationContext!)).toEqual({ + input: 'hello', + computed: { derived: 'HELLO' }, + }); + + testIncrementalValidate(testModel, { input: 'world' }); + expect(getModelData(validationContext!)).toEqual({ + input: 'world', + computed: { derived: 'WORLD' }, + }); + }); + + it('should not cause unnecessary cascading passes when computed returns a new array with same contents', () => { + type TestModel = { + input: string; + computed?: string[]; + }; + + let computeCallCount = 0; + + const testModel = model((root, builder) => [ + builder.withFields(root, ['input', 'computed'], ({ input, computed }) => [ + builder.value( + computed, + builder.compute(input, () => { + computeCallCount++; + return ['a', 'b', 'c']; + }), + ), + ]), + ]); + + testIncrementalValidate(testModel, { input: 'first' }); + expect(getModelData(validationContext!)).toEqual({ + input: 'first', + computed: ['a', 'b', 'c'], + }); + + computeCallCount = 0; + testIncrementalValidate(testModel, { input: 'second' }); + + expect(computeCallCount).toBe(1); + expect(getModelData(validationContext!)).toEqual({ + input: 'second', + computed: ['a', 'b', 'c'], + }); + }); + + it('should not cascade when computed value depends on another computed that returns same structure', () => { + type TestModel = { + input: string; + intermediate?: { value: string }; + final?: string; + }; + + let intermediateCallCount = 0; + let finalCallCount = 0; + + const testModel = model((root, builder) => [ + builder.withFields(root, ['input', 'intermediate', 'final'], ({ input, intermediate, final }) => [ + builder.value( + intermediate, + builder.compute(input, () => { + intermediateCallCount++; + return { value: 'constant' }; + }), + ), + builder.value( + final, + builder.compute(intermediate, intermediate => { + finalCallCount++; + return intermediate?.value ?? ''; + }), + ), + ]), + ]); + + testIncrementalValidate(testModel, { input: 'a' }); + expect(getModelData(validationContext!)).toEqual({ + input: 'a', + intermediate: { value: 'constant' }, + final: 'constant', + }); + + intermediateCallCount = 0; + finalCallCount = 0; + testIncrementalValidate(testModel, { input: 'b' }); + + expect(intermediateCallCount).toBe(1); + // intermediate didn't change structurally, so final should not be re-evaluated + expect(finalCallCount).toBe(0); + expect(getModelData(validationContext!)).toEqual({ + input: 'b', + intermediate: { value: 'constant' }, + final: 'constant', + }); + }); + }); + describe('with undefined values', () => { it('should create object with nested undefined computed value', () => { type TestModel = {