diff --git a/renderers/web_core/src/v0_9/rendering/data-context.test.ts b/renderers/web_core/src/v0_9/rendering/data-context.test.ts index 0aadfacf41..de00b8c2ed 100644 --- a/renderers/web_core/src/v0_9/rendering/data-context.test.ts +++ b/renderers/web_core/src/v0_9/rendering/data-context.test.ts @@ -106,6 +106,25 @@ describe('DataContext', () => { assert.deepStrictEqual(context.resolveDynamicValue(['literal', 'array']), ['literal', 'array']); }); + it('returns fully static arrays as-is without re-allocation', () => { + const staticArray = ['literal', 1, true, null, [2, 'nested']]; + assert.strictEqual(context.resolveDynamicValue(staticArray), staticArray); + }); + + it('resolves arrays of DynamicValues element-wise', () => { + assert.deepStrictEqual(context.resolveDynamicValue([{path: 'name'}, 'literal']), [ + 'Alice', + 'literal', + ]); + }); + + it('resolves DynamicValues nested inside inner arrays', () => { + assert.deepStrictEqual(context.resolveDynamicValue(['outer', [{path: 'name'}, 'inner']]), [ + 'outer', + ['Alice', 'inner'], + ]); + }); + it('subscribes literal arrays as static', () => { let called = false; const sub = context.subscribeDynamicValue(['literal', 'array'], () => { @@ -118,6 +137,84 @@ describe('DataContext', () => { assert.strictEqual(called, false); }); + it('subscribes arrays containing path bindings reactively', () => { + let latest: unknown; + const sub = context.subscribeDynamicValue([{path: 'name'}, 'x'], val => { + latest = val; + }); + assert.deepStrictEqual(sub.value, ['Alice', 'x']); + + context.set('name', 'Bob'); + assert.deepStrictEqual(latest, ['Bob', 'x']); + sub.unsubscribe(); + }); + + it('resolves nested DynamicValues inside and/or function args', () => { + const fnInvoker = (name: string, args: Record) => { + if (name === 'and') return args.values.every((v: unknown) => !!v); + if (name === 'or') return args.values.some((v: unknown) => !!v); + if (name === 'required') { + const val = args.value; + if (val === null || val === undefined) return false; + if (typeof val === 'string' && val === '') return false; + return true; + } + return null; + }; + const root = createTestDataContext( + new DataModel({ + formData: {email: '', phone: '', zip: '', agree: false}, + }), + '/', + fnInvoker, + ); + + const result = root.resolveDynamicValue({ + call: 'and', + args: { + values: [ + {path: '/formData/agree'}, + { + call: 'or', + args: { + values: [ + {call: 'required', args: {value: {path: '/formData/email'}}}, + {call: 'required', args: {value: {path: '/formData/phone'}}}, + ], + }, + }, + {call: 'required', args: {value: {path: '/formData/zip'}}}, + ], + }, + returnType: 'boolean', + }); + assert.strictEqual(result, false); + + root.set('/formData/agree', true); + root.set('/formData/email', 'a@b.com'); + root.set('/formData/zip', '12345'); + const valid = root.resolveDynamicValue({ + call: 'and', + args: { + values: [ + {path: '/formData/agree'}, + { + call: 'or', + args: { + values: [ + {call: 'required', args: {value: {path: '/formData/email'}}}, + {call: 'required', args: {value: {path: '/formData/phone'}}}, + ], + }, + }, + {call: 'required', args: {value: {path: '/formData/zip'}}}, + ], + }, + returnType: 'boolean', + }); + assert.strictEqual(valid, true); + }); + it('resolves function calls synchronously', () => { const fnInvoker = (name: string, args: Record) => { if (name === 'add') return args.a + args.b; diff --git a/renderers/web_core/src/v0_9/rendering/data-context.ts b/renderers/web_core/src/v0_9/rendering/data-context.ts index 629d9a8c57..afec5660ce 100644 --- a/renderers/web_core/src/v0_9/rendering/data-context.ts +++ b/renderers/web_core/src/v0_9/rendering/data-context.ts @@ -75,6 +75,20 @@ export class DataContext { this.dataModel.set(absolutePath, value); } + /** + * Checks whether a value (typically an array element) contains any dynamic parts + * (path bindings or function calls) that require resolution. + */ + private static containsDynamicValue(value: unknown): boolean { + if (value === null || typeof value !== 'object') { + return false; + } + if (Array.isArray(value)) { + return value.some(item => DataContext.containsDynamicValue(item)); + } + return 'path' in value || 'call' in value; + } + /** * Synchronously evaluates a `DynamicValue` (a literal, a path binding, or a function call) * into its concrete runtime value. @@ -87,11 +101,20 @@ export class DataContext { * @returns The synchronously resolved value. */ resolveDynamicValue(value: DynamicValue): V { - // 1. Literal check (excluding arrays and objects) - if (value === null || typeof value !== 'object' || Array.isArray(value)) { + // 1. Primitive literals (null, string, number, boolean) + if (value === null || typeof value !== 'object') { return value as V; } + // 1b. Arrays: each element may itself be a DynamicValue (e.g. `and`/`or` `values`) + if (Array.isArray(value)) { + // Fast path: fully static arrays need no per-element resolution. + if (!DataContext.containsDynamicValue(value)) { + return value as V; + } + return value.map(item => this.resolveDynamicValue(item)) as V; + } + // 2. Path Check: { path: "..." } if ('path' in value) { const absolutePath = this.resolvePath((value as DataBinding).path); @@ -173,11 +196,27 @@ export class DataContext { * @returns A Preact Signal containing the reactive result of the evaluation. */ resolveSignal(value: DynamicValue): Signal { - // 1. Literal - if (typeof value !== 'object' || value === null || Array.isArray(value)) { + // 1. Primitive literals + if (typeof value !== 'object' || value === null) { return signal(value as V); } + // 1b. Arrays: each element may itself be a DynamicValue (e.g. `and`/`or` `values`) + if (Array.isArray(value)) { + // Fast path: fully static arrays need no per-element signals. + if (!DataContext.containsDynamicValue(value)) { + return signal(value as V); + } + const itemSignals = value.map(item => this.resolveSignal(item)); + const resultSig = computed(() => itemSignals.map(s => getValue(s))) as Signal; + resultSig.unsubscribe = () => { + for (const s of itemSignals) { + s.unsubscribe?.(); + } + }; + return resultSig; + } + // 2. Path Check if ('path' in value) { const absolutePath = this.resolvePath((value as DataBinding).path);