From 744b781463cf9e015bb49c6a4c47677af3ec8e07 Mon Sep 17 00:00:00 2001 From: Alex Kanunnikov Date: Tue, 2 Jul 2024 13:40:55 +0300 Subject: [PATCH] chore: improve scopes lookup (#167) * improve scopes lookup * + --- plugins/constants.ts | 60 ++++++++++++ plugins/converter.test.ts | 58 ++++++----- plugins/converter.ts | 103 ++++++++------------ plugins/symbols.ts | 4 + plugins/test.ts | 29 ++++-- src/tests/integration/dash-helpers-test.gts | 48 +++++++++ src/tests/integration/let-test.gts | 12 +++ src/tests/integration/or-test.gts | 7 ++ src/utils/dom.ts | 23 ++++- 9 files changed, 244 insertions(+), 100 deletions(-) create mode 100644 plugins/constants.ts create mode 100644 src/tests/integration/dash-helpers-test.gts diff --git a/plugins/constants.ts b/plugins/constants.ts new file mode 100644 index 0000000..f473964 --- /dev/null +++ b/plugins/constants.ts @@ -0,0 +1,60 @@ +export const booleanAttributes = [ + 'checked', + 'readonly', + 'autoplay', + 'allowfullscreen', + 'async', + 'autofocus', + 'autoplay', + 'controls', + 'default', + 'defer', + 'disabled', + 'formnovalidate', + 'inert', + 'ismap', + 'itemscope', + 'loop', + 'multiple', + 'muted', + 'nomodule', + 'novalidate', + 'open', + 'playsinline', + 'required', + 'reversed', + 'selected', +]; + +export const propertyKeys = [ + 'class', + 'shadowrootmode', + // boolean attributes (https://meiert.com/en/blog/boolean-attributes-of-html/) + 'checked', + 'readonly', + 'value', + 'autoplay', + 'allowfullscreen', + 'async', + 'autofocus', + 'autoplay', + 'controls', + 'default', + 'defer', + 'disabled', + 'formnovalidate', + 'inert', + 'ismap', + 'itemscope', + 'loop', + 'multiple', + 'muted', + 'nomodule', + 'novalidate', + 'open', + 'playsinline', + 'required', + 'reversed', + 'selected', +]; +export const COMPILE_TIME_HELPERS = ['has-block-params', 'has-block']; \ No newline at end of file diff --git a/plugins/converter.test.ts b/plugins/converter.test.ts index 8f16d1a..7b63cb0 100644 --- a/plugins/converter.test.ts +++ b/plugins/converter.test.ts @@ -34,11 +34,15 @@ function $mm(name: string, params: string = '', hash: string = '{}') { // Maybe helper function $mh(name: string, params: string = '', hash: string = '{}') { const isBuiltin = ['or','and'].includes(name); + const isFromScope = name.includes('-'); if (isBuiltin) { name = '$__' + name; } - if (!isBuiltin && flags.WITH_HELPER_MANAGER) { - return `$:$_maybeHelper(${name},[${params}],${hash})`; + if (isFromScope || (!isBuiltin && flags.WITH_HELPER_MANAGER)) { + if (isFromScope) { + hash = '{$_scope: ()=>this[$args]?.$_scope}'; + } + return `$:$_maybeHelper(${isFromScope ? JSON.stringify(name) : name},[${params}],${hash})`; } else { return `$:${name}(${params})`; } @@ -65,9 +69,9 @@ function $glimmerCompat(str: string) { function $s(node: T): string | null | undefined { return serializeNode(node); } -function $t(tpl: string): ComplexJSType { +function $t(tpl: string, scopes: string[] = []): ComplexJSType { const seenNodes: Set = new Set(); - const { ToJSType } = convert(seenNodes, flags); + const { ToJSType } = convert(seenNodes, flags, new Set(scopes)); const ast = preprocess(tpl); const node = ast.body[0] as T; return ToJSType(node); @@ -135,7 +139,7 @@ describe.each([ }); describe('support concat expressions', () => { test('in attribute', () => { - const converted = $t(``); + const converted = $t(``,['t']); expect(converted).toEqual($node({ tag: 'Panel', attributes: [['@title', "$:() => [\"1. \",$:t.document].join('')"]], @@ -229,7 +233,7 @@ describe.each([ test('it has proper chains', () => { expect( $t( - `{{toInitials @name @initialLength @initials}}`, + `{{toInitials @name @initialLength @initials}}`,['toInitials'] ), ).toEqual( `$:() => ` + @@ -241,6 +245,7 @@ describe.each([ expect( $t( `{{toInitials @name @initialLength.a @initials}}`, + ['toInitials'], ), ).toEqual( `$:() => ` + @@ -260,10 +265,10 @@ describe.each([ expect($t(`{{this.foo.bar}}`)).toEqual( `$:this.foo?.bar`, ); - expect($t(`{{foo.bar.baz}}`)).toEqual( + expect($t(`{{foo.bar.baz}}`, ['foo'])).toEqual( `$:foo?.bar?.baz`, ); - expect($t(`{{foo.bar}}`)).toEqual(`$:foo.bar`); + expect($t(`{{foo.bar}}`, ['foo'])).toEqual(`$:foo.bar`); expect($t(`{{@foo.bar.baz}}`)).toEqual( `$:this[$args].foo?.bar?.baz`, ); @@ -274,7 +279,7 @@ describe.each([ test('it works for sub expression paths in mustache', () => { expect( $t( - `
`, + `
`, ['maybeClass'], ), ).toEqual( $node({ @@ -437,43 +442,43 @@ describe.each([ }); describe('Builtin helpers in SubExpression', () => { test('fn helper properly mapped', () => { - expect($t(`{{q (fn a b (if c d))}}`)).toEqual( + expect($t(`{{q (fn a b (if c d))}}`, ['q'])).toEqual( `$:() => ${$mh('q', `$:$__fn($:a,$:b,$:$__if($:c,$:d))`)}`, ); }); test('if helper properly mapped', () => { - expect($t(`{{q (if a b (if c d))}}`)).toEqual( + expect($t(`{{q (if a b (if c d))}}`, ['q'])).toEqual( `$:() => ${$mh('q', `$:$__if($:a,$:b,$:$__if($:c,$:d))`)}`, ); }); test('unless helper properly mapped', () => { expect( - $t(`{{q (unless a b (if c d))}}`), + $t(`{{q (unless a b (if c d))}}`,['q']), ).toEqual(`$:() => ${$mh('q', `$:$__if($:a,$:$__if($:c,$:d),$:b)`)}`); }); test('eq helper properly mapped', () => { - expect($t(`{{q (eq a b)}}`)).toEqual( + expect($t(`{{q (eq a b)}}`,['q'])).toEqual( `$:() => ${$mh('q', `$:$__eq($:a,$:b)`)}`, ); }); test('debugger helper properly mapped', () => { - expect($t(`{{q (debugger a)}}`)).toEqual( + expect($t(`{{q (debugger a)}}`,['q'])).toEqual( `$:() => ${$mh('q', `$:$__debugger.call($:this,$:a)`)}`, ); }); test('log helper properly mapped', () => { - expect($t(`{{q (log a b)}}`)).toEqual( + expect($t(`{{q (log a b)}}`,['q'])).toEqual( `$:() => ${$mh('q', `$:$__log($:a,$:b)`)}`, ); }); test('array helper properly mapped', () => { expect( - $t(`{{q (array foo "bar" "baz")}}`), + $t(`{{q (array foo "bar" "baz")}}`, ['q']), ).toEqual(`$:() => ${$mh('q', `$:$__array($:foo,"bar","baz")`)}`); }); test('hash helper properly mapped', () => { expect( - $t(`{{q (hash foo="bar" boo="baz")}}`), + $t(`{{q (hash foo="bar" boo="baz")}}`, ['q']), ).toEqual(`$:() => ${$mh('q', `$:$__hash({foo: "bar", boo: "baz"})`)}`); }); }); @@ -499,7 +504,7 @@ describe.each([ }); describe('MustacheStatement', () => { test('converts a args-less path', () => { - expect($t(`{{foo-bar}}`)).toEqual(`$:foo-bar`); + expect($t(`{{foo-bar}}`)).toEqual(`${$mh('foo-bar')}`); }); test('converts a path with args', () => { expect($t(`{{foo-bar bas boo}}`)).toEqual( @@ -532,7 +537,7 @@ describe.each([ }); test('support bool, null, undefined as helper args', () => { expect( - $t(`{{foo true null undefined}}`), + $t(`{{foo true null undefined}}`, ['foo']), ).toEqual(`$:() => ${$mh('foo', 'true,null,undefined')}`); }); }); @@ -568,7 +573,7 @@ describe.each([ }); test('converts a simple element with concat string attribute', () => { expect( - $t(`
`), + $t(`
`, ['foo', 'boo']), ).toEqual( $node({ tag: 'div', @@ -579,7 +584,7 @@ describe.each([ ); }); test('converts a simple element with path attribute', () => { - expect($t(`
`)).toEqual( + expect($t(`
`, ['foo'])).toEqual( $node({ tag: 'div', properties: [['', '$:foo']], @@ -588,7 +593,7 @@ describe.each([ }); test('converts a simple element with path attribute with string literal', () => { expect( - $t(`
`), + $t(`
`,['foo']), ).toEqual( $node({ tag: 'div', @@ -597,7 +602,7 @@ describe.each([ ); }); test('converts a simple element with path attribute with path literal', () => { - expect($t(`
`)).toEqual( + expect($t(`
`, ['foo'])).toEqual( $node({ tag: 'div', properties: [['', `$:() => ${$mh('foo', '$:bar')}`]], @@ -616,7 +621,7 @@ describe.each([ test('converts a simple element with `on` modifier, with composed args', () => { // @todo - likely need to return proper closure here (arrow function) expect( - $t(`
`), + $t(`
`,['foo']), ).toEqual( $node({ tag: 'div', @@ -636,7 +641,7 @@ describe.each([ }); test('support helper as on modifier argument', () => { const result = $t( - `
`, + `
`,['optional'], ); expect(result).toEqual( $node({ @@ -714,7 +719,7 @@ describe.each([ test('helper in condition', () => { expect( - $t(`{{#if (foo bar)}}123{{else}}456{{/if}}`), + $t(`{{#if (foo bar)}}123{{else}}456{{/if}}`, ['foo']), ).toEqual( $control({ type: 'if', @@ -727,6 +732,7 @@ describe.each([ expect( $t( `{{#unless (foo bar)}}123{{else}}456{{/unless}}`, + ['foo'], ), ).toEqual( $control({ diff --git a/plugins/converter.ts b/plugins/converter.ts index 814ec02..b30eb30 100644 --- a/plugins/converter.ts +++ b/plugins/converter.ts @@ -14,8 +14,9 @@ import { toOptionalChaining, toSafeJSPath, } from './utils'; -import { EVENT_TYPE, SYMBOLS } from './symbols'; +import { CONSTANTS, EVENT_TYPE, SYMBOLS } from './symbols'; import type { Flags } from './flags'; +import { booleanAttributes, COMPILE_TIME_HELPERS, propertyKeys } from './constants'; const SPECIAL_HELPERS = [ SYMBOLS.HELPER_HELPER, @@ -130,19 +131,32 @@ export function convert( } } + function hasResolvedBinding(fnPath: string) { + let hasBinding = fnPath.startsWith('$_') || fnPath.startsWith('this.') || fnPath.startsWith('this[') || bindings.has(fnPath.split('.')[0]?.split('?')[0]); + if (COMPILE_TIME_HELPERS.includes(fnPath)) { + hasBinding = false; + } + return hasBinding; + } + function toHelper( nodePath: string, params: any[], hash: [string, PrimitiveJSType][], ) { const fnPath = resolvePath(nodePath); + if (SPECIAL_HELPERS.includes(fnPath)) { return `$:${fnPath}([${params .map((p) => serializeParam(p)) .join(',')}],${toObject(hash)})`; } - if (flags.WITH_HELPER_MANAGER && !nodePath.startsWith('$_')) { - return `$:${SYMBOLS.$_maybeHelper}(${fnPath},[${params + let hasBinding = hasResolvedBinding(fnPath); + if ((!hasBinding || flags.WITH_HELPER_MANAGER) && !nodePath.startsWith('$_')) { + if (!hasBinding) { + hash.push([CONSTANTS.SCOPE_KEY, `$:()=>this[${SYMBOLS.$args}]?.${CONSTANTS.SCOPE_KEY}`]); + } + return `$:${SYMBOLS.$_maybeHelper}(${hasBinding ? fnPath : JSON.stringify(fnPath)},[${params .map((p) => serializeParam(p)) .join(',')}],${toObject(hash)})`; } else { @@ -275,7 +289,13 @@ export function convert( )})`; } if (hashArgs.length === 0) { - return ToJSType(node.path); + const fnPath = resolvePath(node.path.original); + let hasBinding = hasResolvedBinding(fnPath); + if (!hasBinding) { + return toHelper(node.path.original, [], hashArgs); + } else { + return ToJSType(node.path); + } } return toHelper(node.path.original, [], hashArgs); } else { @@ -289,7 +309,12 @@ export function convert( if (!node.params.length) { return null; } + node.program.blockParams.forEach((p) => { + bindings.add(p); + }); const childElements = resolvedChildren(node.program.body); + + const elseChildElements = node.inverse?.body ? resolvedChildren(node.inverse.body) : undefined; @@ -322,7 +347,9 @@ export function convert( const children = childElements?.map((el) => ToJSType(el)) ?? null; const inverse = elseChildElements?.map((el) => ToJSType(el)) ?? null; - + node.program.blockParams.forEach((p) => { + bindings.delete(p); + }); if (name === 'in-element') { return { type: 'in-element', @@ -439,65 +466,9 @@ export function convert( return false; } - const booleanAttributes = [ - 'checked', - 'readonly', - 'autoplay', - 'allowfullscreen', - 'async', - 'autofocus', - 'autoplay', - 'controls', - 'default', - 'defer', - 'disabled', - 'formnovalidate', - 'inert', - 'ismap', - 'itemscope', - 'loop', - 'multiple', - 'muted', - 'nomodule', - 'novalidate', - 'open', - 'playsinline', - 'required', - 'reversed', - 'selected', - ]; + + - const propertyKeys = [ - 'class', - 'shadowrootmode', - // boolean attributes (https://meiert.com/en/blog/boolean-attributes-of-html/) - 'checked', - 'readonly', - 'value', - 'autoplay', - 'allowfullscreen', - 'async', - 'autofocus', - 'autoplay', - 'controls', - 'default', - 'defer', - 'disabled', - 'formnovalidate', - 'inert', - 'ismap', - 'itemscope', - 'loop', - 'multiple', - 'muted', - 'nomodule', - 'novalidate', - 'open', - 'playsinline', - 'required', - 'reversed', - 'selected', - ]; const propsToCast = { class: '', // className readonly: 'readOnly', @@ -509,9 +480,15 @@ export function convert( } function ElementToNode(element: ASTv1.ElementNode): HBSNode { + element.blockParams.forEach((p) => { + bindings.add(p); + }); const children = resolvedChildren(element.children) .map((el) => ToJSType(el)) .filter((el) => el !== null); + element.blockParams.forEach((p) => { + bindings.delete(p); + }); const rawStyleEvents = element.attributes.filter((attr) => { return attr.name.startsWith('style.'); diff --git a/plugins/symbols.ts b/plugins/symbols.ts index 30196c3..6007066 100644 --- a/plugins/symbols.ts +++ b/plugins/symbols.ts @@ -40,6 +40,10 @@ export const SYMBOLS = { $__and: '$__and', }; +export const CONSTANTS = { + SCOPE_KEY: '$_scope', +} + export const EVENT_TYPE = { ON_CREATED: '0', TEXT_CONTENT: '1', diff --git a/plugins/test.ts b/plugins/test.ts index f477376..7f97854 100644 --- a/plugins/test.ts +++ b/plugins/test.ts @@ -11,7 +11,6 @@ import { type HBSControlExpression, type HBSNode, serializeNode, - setBindings, } from './utils'; import { processTemplate, type ResolvedHBS } from './babel'; import { convert } from './converter'; @@ -70,13 +69,14 @@ function processTransformedFiles( programs: Programs, programResults: string[], ) { + const txt = babelResult?.code ?? ''; - const { ToJSType, ElementToNode } = convert(seenNodes, flags); + const globalFlags = flags; hbsToProcess.forEach((content) => { const flags = content.flags; - setBindings(content.bindings); + const { ToJSType, ElementToNode } = convert(seenNodes, globalFlags, content.bindings); const ast = preprocess(content.template); const program: (typeof programs)[number] = { meta: flags, @@ -116,13 +116,24 @@ function processTransformedFiles( // @ts-expect-error fix-here program.template.push(ToJSType(node)); }, - ElementNode(node) { - if (seenNodes.has(node)) { - return; + ElementNode: { + enter(node) { + if (seenNodes.has(node)) { + return; + } + node.blockParams.forEach((p) => { + content.bindings.add(p); + }); + seenNodes.add(node); + program.template.push(ElementToNode(node)); + + }, + exit(node) { + node.blockParams.forEach((p) => { + content.bindings.delete(p); + }); } - seenNodes.add(node); - program.template.push(ElementToNode(node)); - }, + } }); programs.push(program); }); diff --git a/src/tests/integration/dash-helpers-test.gts b/src/tests/integration/dash-helpers-test.gts new file mode 100644 index 0000000..31d9497 --- /dev/null +++ b/src/tests/integration/dash-helpers-test.gts @@ -0,0 +1,48 @@ +import { module, test } from 'qunit'; +import { Component } from '@lifeart/gxt'; +import { render } from '@lifeart/gxt/test-utils'; +import { CONSTANTS } from '../../../plugins/symbols'; +module('Integration | DashHelpers | x-bar', function () { + test('we able to resolve helper from scope by default', async function (assert) { + function borf(value: string) { + return value; + } + class Basic extends Component { + + } + await render(); + assert.dom().hasText('YES'); + }); + test('dashed hlpers wrapped with helper manager', async function (assert) { + const scope = { + 'x-borf': function (value: string) { + return value; + }, + }; + class Basic extends Component { + constructor() { + super(...arguments); + this.args[CONSTANTS.SCOPE_KEY] = () => [scope]; + } + + } + await render(); + assert.dom().hasText('YES'); + }); + test('dashed hlpers without args wrapped with helper manager', async function (assert) { + const scope = { + 'x-borf': function () { + return 'YES'; + }, + }; + class Basic extends Component { + constructor() { + super(...arguments); + this.args[CONSTANTS.SCOPE_KEY] = () => [scope]; + } + + } + await render(); + assert.dom().hasText('YES'); + }); +}); diff --git a/src/tests/integration/let-test.gts b/src/tests/integration/let-test.gts index 31f01f4..42c79bc 100644 --- a/src/tests/integration/let-test.gts +++ b/src/tests/integration/let-test.gts @@ -149,4 +149,16 @@ module('Integration | InternalComponent | let', function () { ); assert.dom('[data-test="123"]').hasText('123'); }); + test('let scope values resolvable', async function (assert) { + await render( + , + ); + assert.dom('[data-test-foo]').hasText('foo'); + assert.dom('[data-test-bar]').hasText('bar'); + }); }); diff --git a/src/tests/integration/or-test.gts b/src/tests/integration/or-test.gts index 50a08aa..ea00480 100644 --- a/src/tests/integration/or-test.gts +++ b/src/tests/integration/or-test.gts @@ -8,4 +8,11 @@ module('Integration | InternalHelper | or', function () { await render(); assert.dom().hasText('1'); }); + test('custom or helper could be used if located in scope', async function (assert) { + const or = () => 42; + await render(); + assert.dom().hasText('42'); + await render(); + assert.dom().hasText('42'); + }); }); diff --git a/src/utils/dom.ts b/src/utils/dom.ts index 31bdb3e..0ed0399 100644 --- a/src/utils/dom.ts +++ b/src/utils/dom.ts @@ -46,6 +46,7 @@ import { import { isRehydrationScheduled } from './ssr/rehydration'; import { createHotReload } from './hmr'; import { IfCondition } from './control-flow/if'; +import { CONSTANTS } from '../../plugins/symbols'; type RenderableType = Node | ComponentReturnType | string | number; type ShadowRootMode = 'open' | 'closed' | null; @@ -663,17 +664,35 @@ if (!import.meta.env.SSR) { } } +export function $_GET_SCOPES(hash: Record) { + // @ts-expect-error typings + return hash[CONSTANTS.SCOPE_KEY]?.() || []; +} + export const $_maybeHelper = ( value: any, - // @ts-expect-error args: any[], _hash: Record, ) => { // @ts-expect-error amount of args const hash = $_args(_hash, false); + if (WITH_EMBER_INTEGRATION) { + if ($_MANAGERS.helper.canHandle(value)) { + return $_MANAGERS.helper.handle(value, args, _hash); + } + } // helper manager if (isPrimitive(value)) { - return value; + const scopes = $_GET_SCOPES(hash); + const needleScope = scopes.find((scope: Record) => { + return value in scope; + }); + + if (needleScope) { + return needleScope[value](...args); + } else { + return value; + } // @ts-expect-error } else if (EmberFunctionalHelpers.has(value)) { return (...args: any[]) => {