Skip to content
97 changes: 97 additions & 0 deletions renderers/web_core/src/v0_9/rendering/data-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'], () => {
Expand All @@ -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<string, any>) => {
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<string, any>) => {
if (name === 'add') return args.a + args.b;
Expand Down
47 changes: 43 additions & 4 deletions renderers/web_core/src/v0_9/rendering/data-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -87,11 +101,20 @@ export class DataContext {
* @returns The synchronously resolved value.
*/
resolveDynamicValue<V>(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;
}
Comment thread
kokoro-ele marked this conversation as resolved.

// 2. Path Check: { path: "..." }
if ('path' in value) {
const absolutePath = this.resolvePath((value as DataBinding).path);
Expand Down Expand Up @@ -173,11 +196,27 @@ export class DataContext {
* @returns A Preact Signal containing the reactive result of the evaluation.
*/
resolveSignal<V>(value: DynamicValue): Signal<V> {
// 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<V>;
resultSig.unsubscribe = () => {
for (const s of itemSignals) {
s.unsubscribe?.();
}
};
return resultSig;
}
Comment thread
kokoro-ele marked this conversation as resolved.

// 2. Path Check
if ('path' in value) {
const absolutePath = this.resolvePath((value as DataBinding).path);
Expand Down