From 1b02863c00f5bf91a78670745c794b1fe71fa52b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mike=20Repe=C4=87?= Date: Fri, 16 Oct 2026 12:25:04 +0000 Subject: [PATCH] fix(core): Hand node reference results without an item shape to the engine (no-changelog) (#41083) --- .../native-evaluation/evaluator.ts | 8 ++++++ .../test/native-evaluation-parity.test.ts | 26 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/packages/workflow/src/expressions/native-evaluation/evaluator.ts b/packages/workflow/src/expressions/native-evaluation/evaluator.ts index 80dde1648c3..282b9e8d5e1 100644 --- a/packages/workflow/src/expressions/native-evaluation/evaluator.ts +++ b/packages/workflow/src/expressions/native-evaluation/evaluator.ts @@ -508,11 +508,19 @@ function evalNodeRefCall(proxy: unknown, method: string, env: Env): unknown { if (typeof fn !== 'function') throw new EngineFallbackError(); const result = bounded(fn.call(proxy)); + + // A node name that is an Object.prototype key resolves through a plain + // object lookup on the host. Only the item shapes stay native. + const expected = method === 'all' ? isArray(result) : result === undefined || isItem(result); + if (!expected) throw new EngineFallbackError(); + env.charge(isArray(result) ? result.length : 1); return result; } +const isItem = (value: unknown): boolean => isObj(value) && !isArray(value); + /** * A captured Luxon getter or zero-argument method on `$now`/`$today`. The * root is the proxy's own DateTime (anything else is the engine's), and the diff --git a/packages/workflow/test/native-evaluation-parity.test.ts b/packages/workflow/test/native-evaluation-parity.test.ts index cd3353bf9dd..c9bc720b663 100644 --- a/packages/workflow/test/native-evaluation-parity.test.ts +++ b/packages/workflow/test/native-evaluation-parity.test.ts @@ -731,6 +731,32 @@ describe('Expression - fast native evaluation parity', () => { expect(nativeOn(expr, visits(fits + 2))).toEqual({ handled: false }); expect(nativeOn(expr, visits(fits))).toEqual({ handled: true, value: false }); }); + + // `$('hasOwnProperty')` resolves through a plain object lookup on the + // host and hands back Object.prototype.hasOwnProperty, not item data. + test('a node reference call that returns no item shape hands off to the engine', () => { + const { hasOwnProperty } = Object.prototype; + const prototypeNode = { + $: () => ({ all: () => hasOwnProperty, first: () => hasOwnProperty }), + }; + expect(nativeOn("{{ $('hasOwnProperty').all().name }}", prototypeNode)).toEqual({ + handled: false, + }); + expect(nativeOn("{{ $('hasOwnProperty').first() }}", prototypeNode)).toEqual({ + handled: false, + }); + const item = { json: { a: 1 } }; + const node = { $: () => ({ all: () => [item], first: () => item, last: () => undefined }) }; + expect(nativeOn("{{ $('Source').all().length }}", node)).toEqual({ handled: true, value: 1 }); + expect(nativeOn("{{ $('Source').first().json.a }}", node)).toEqual({ + handled: true, + value: 1, + }); + expect(nativeOn("{{ $('Source').last() }}", node)).toEqual({ + handled: true, + value: undefined, + }); + }); }); // extendSyntax rewrites calls to extension-named methods into extend()