From dfcf7f4b43dfcd800983a3bd90fc24084f3edef2 Mon Sep 17 00:00:00 2001 From: Ahmed Yasin Koculu Date: Tue, 24 Feb 2026 08:35:15 +0100 Subject: [PATCH 1/4] micro optimization for hot paths --- src/api/types.ts | 4 + src/bind/Binder.ts | 103 +++-- src/bind/ComponentBinder.ts | 38 +- src/bind/DirectiveCollector.ts | 115 +++-- src/bind/DynamicBinder.ts | 5 +- src/bind/ForBinder.ts | 93 +++- src/bind/IfBinder.ts | 9 +- src/cleanup/removeNode.ts | 26 +- src/cleanup/unbind.ts | 29 +- src/common/common.ts | 49 ++- src/common/looseEqual.ts | 18 +- src/directives/attr.ts | 2 +- src/directives/on.ts | 4 +- src/directives/prop.ts | 2 +- src/directives/single-prop.ts | 9 +- src/directives/value.ts | 2 +- src/index.ts | 1 + src/observer/observe.ts | 3 +- src/parser/Parser.ts | 66 ++- src/parser/jsep/jsep.ts | 782 ++++++++++++++++++--------------- src/reactivity/isRef.ts | 2 +- src/reactivity/unref.ts | 7 +- 22 files changed, 856 insertions(+), 513 deletions(-) diff --git a/src/api/types.ts b/src/api/types.ts index e0ac0fc..0a9685b 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -209,6 +209,10 @@ export type Unbinder = () => void export interface ParseResult { value: SRef stop: StopObserving + subscribe?: ( + observer: ObserveCallback, + init?: boolean, + ) => StopObserving refs: Array context: Record } diff --git a/src/bind/Binder.ts b/src/bind/Binder.ts index a437c65..d150883 100644 --- a/src/bind/Binder.ts +++ b/src/bind/Binder.ts @@ -11,7 +11,6 @@ import { import { isNullOrWhitespace } from '../common/is-what' import { teleportDirective } from '../directives/teleport' // import { warning, WarningType } from '../log/warnings' -import { observe } from '../observer/observe' import { type Parser } from '../parser/Parser' import { ComponentBinder } from './ComponentBinder' import { DirectiveCollector } from './DirectiveCollector' @@ -90,9 +89,11 @@ export class Binder { console.error('directive not found:', name) continue } - item.__elements.forEach((el) => { + const elements = item.__elements + for (let i = 0; i < elements.length; ++i) { + const el = elements[i] this.__bind(directive, el, attribute, false, option, item.__flags) - }) + } } } @@ -108,10 +109,13 @@ export class Binder { const bindExpression = el.getAttribute(attribute) el.removeAttribute(attribute) const getParentSwitch = (el: HTMLElement): string | null => { - const switchId = el.getAttribute(rswitch) - if (switchId) return switchId - if (!el.parentElement) return null - return getParentSwitch(el.parentElement) + let cursor: HTMLElement | null = el + while (cursor) { + const switchId = cursor.getAttribute(rswitch) + if (switchId) return switchId + cursor = cursor.parentElement + } + return null } if (hasSwitch()) { const switchId = getParentSwitch(el) @@ -167,11 +171,12 @@ export class Binder { config.once, ) const stopObserverList = [] as StopObserving[] + const hasOnChange = !!config.onChange const unbinder = (): void => { result.stop() dynamicOption?.stop() - for (const stopObserver of stopObserverList) { - stopObserver() + for (let i = 0; i < stopObserverList.length; ++i) { + stopObserverList[i]() } stopObserverList.length = 0 } @@ -188,38 +193,60 @@ export class Binder { config.once, ) } - let previousValues: any[] - const getValues = (): any[] => { - previousValues = result.value() - return previousValues - } - let previousOption: any - const getOptionValue = (): any => { - if (!dynamicOption) { - previousOption = option - return option - } - previousOption = dynamicOption.value()[0] - return previousOption - } + const dynamicOpt = dynamicOption + const hasDynamicOption = dynamicOpt != null + let previousValues: any[] = result.value() + let previousOption: any = hasDynamicOption + ? dynamicOption!.value()[0] + : option - const observeTailChanges = (): void => { - if (!config.onChange) return - const stopObserving = observe(result.value, (_) => { - const pre = previousValues - const pre2 = previousOption - config.onChange?.(el, getValues(), pre, getOptionValue(), pre2, flags) - }) + if (!config.once && hasOnChange) { + const stopObserving = ( + result.subscribe + ? result.subscribe(() => { + const preValues = previousValues + const preOption = previousOption + const nextValues = result.value() + const nextOption = hasDynamicOption + ? dynamicOpt.value()[0] + : option + previousValues = nextValues + previousOption = nextOption + config.onChange?.( + el, + nextValues, + preValues, + nextOption, + preOption, + flags, + ) + }) + : () => {} + ) as StopObserving stopObserverList.push(stopObserving) - if (dynamicOption) { - const stopObserving = observe(dynamicOption.value, (_) => { - const pre = previousOption - config.onChange?.(el, getValues(), pre, getOptionValue(), pre, flags) - }) + if (dynamicOpt) { + const stopObserving = ( + dynamicOpt.subscribe + ? dynamicOpt.subscribe(() => { + const preOption = previousOption + const nextValues = result.value() + const nextOption = dynamicOpt.value()[0] + previousValues = nextValues + previousOption = nextOption + config.onChange?.( + el, + nextValues, + preOption, + nextOption, + preOption, + flags, + ) + }) + : () => {} + ) as StopObserving stopObserverList.push(stopObserving) } } - if (!config.once) observeTailChanges() if (config.onBind) stopObserverList.push( config.onBind( @@ -233,9 +260,9 @@ export class Binder { ) config.onChange?.( el, - getValues(), + previousValues, undefined, - getOptionValue(), + previousOption, undefined, flags, ) diff --git a/src/bind/ComponentBinder.ts b/src/bind/ComponentBinder.ts index ca257be..47f15ba 100644 --- a/src/bind/ComponentBinder.ts +++ b/src/bind/ComponentBinder.ts @@ -31,6 +31,8 @@ export class ComponentBinder { __binder: Binder __inherit: string + __registeredComponentSelector = '' + __registeredComponentSize = -1 constructor(binder: Binder) { this.__binder = binder @@ -41,17 +43,49 @@ export class ComponentBinder { this.__unwrapComponents(element) } + __getRegisteredComponentSelector( + registeredComponents: Map, + ): string { + if (this.__registeredComponentSize !== registeredComponents.size) { + const names = [...registeredComponents.keys()] + this.__registeredComponentSelector = names + .concat(names.map(hyphenate)) + .join(',') + this.__registeredComponentSize = registeredComponents.size + } + return this.__registeredComponentSelector + } + + __hasContextComponents(contexts: any[]): boolean { + for (let i = 0; i < contexts.length; ++i) { + const components = contexts[i]?.components + if (!components) continue + for (const _ in components) { + return true + } + } + return false + } + __unwrapComponents(element: Element): void { const binder = this.__binder const parser = binder.__parser const registeredComponents = binder.__config.__components const registeredComponentsUpperCase = binder.__config.__componentsUpperCase + if ( + registeredComponents.size === 0 && + !this.__hasContextComponents(parser.__contexts) + ) { + return + } + const contextComponents = parser.__getComponents() const contextComponentSelectors = parser.__getComponentSelectors() + const registeredSelector = + this.__getRegisteredComponentSelector(registeredComponents) const selector = [ - ...registeredComponents.keys(), + ...(registeredSelector ? [registeredSelector] : []), ...contextComponentSelectors, - ...[...registeredComponents.keys()].map(hyphenate), ...contextComponentSelectors.map(hyphenate), ].join(',') if (isNullOrWhitespace(selector)) return diff --git a/src/bind/DirectiveCollector.ts b/src/bind/DirectiveCollector.ts index 4b4e9ea..e61a5ff 100644 --- a/src/bind/DirectiveCollector.ts +++ b/src/bind/DirectiveCollector.ts @@ -6,41 +6,17 @@ import { type Binder } from './Binder' * @internal */ export class DirectiveElement { - __name: string // r-on @click @submit r-on:click.prevent @submit.prevent @[event-name].self.camel :src :className.prop .class-name.camel r-if r-for key - /** Contains: `['@', 'submit'], ['r-on', 'click'], ['\\@', '[dynamicKey]']` */ - __terms: string[] = [] + __terms: string[] /** Contains directive flags. ['camel', 'prevent',...] */ - __flags: string[] = [] + __flags: string[] __elements: HTMLElement[] = [] - constructor(name: string) { - this.__name = name - this.__parse() - } - - __parse(): void { - let name = this.__name - const isPropShortcut = name.startsWith('.') - if (isPropShortcut) name = ':' + name.slice(1) - const firstFlagIndex = name.indexOf('.') - const terms = (this.__terms = ( - firstFlagIndex < 0 ? name : name.substring(0, firstFlagIndex) - ).split(/[:@]/)) - if (isNullOrWhitespace(terms[0])) terms[0] = isPropShortcut ? '.' : name[0] - - if (firstFlagIndex >= 0) { - const flags = (this.__flags = name.slice(firstFlagIndex + 1).split('.')) - if (flags.includes('camel')) { - const index = terms.length - 1 - terms[index] = camelize(terms[index]) - } - if (flags.includes('prop')) { - terms[0] = '.' - } - } + constructor(terms: string[], flags: string[]) { + this.__terms = terms + this.__flags = flags } } @@ -52,28 +28,93 @@ export type DirectiveMap = Map export class DirectiveCollector { __binder: Binder __prefixes: string[] + __prefixesByHead: Map + __parsedNameCache: Map constructor(binder: Binder) { this.__binder = binder this.__prefixes = binder.__config.__getPrefixes() + this.__parsedNameCache = new Map() + const groups = new Map() + for (const p of this.__prefixes) { + const key = p[0] ?? '' + const list = groups.get(key) + if (list) list.push(p) + else groups.set(key, [p]) + } + this.__prefixesByHead = groups + } + + __parseName(name: string): { terms: string[]; flags: string[] } { + const cached = this.__parsedNameCache.get(name) + if (cached) return cached + + let normalized = name + const isPropShortcut = normalized.startsWith('.') + if (isPropShortcut) normalized = ':' + normalized.slice(1) + + const firstFlagIndex = normalized.indexOf('.') + const rawTerms = + firstFlagIndex < 0 ? normalized : normalized.substring(0, firstFlagIndex) + const terms = rawTerms.split(/[:@]/) + if (isNullOrWhitespace(terms[0])) { + terms[0] = isPropShortcut ? '.' : normalized[0] + } + + const flags = + firstFlagIndex >= 0 ? normalized.slice(firstFlagIndex + 1).split('.') : [] + let hasCamel = false + let hasProp = false + for (let i = 0; i < flags.length; ++i) { + const f = flags[i] + if (!hasCamel && f === 'camel') hasCamel = true + else if (!hasProp && f === 'prop') hasProp = true + if (hasCamel && hasProp) break + } + if (hasCamel) terms[terms.length - 1] = camelize(terms[terms.length - 1]) + if (hasProp) terms[0] = '.' + + const parsed = { terms, flags } + this.__parsedNameCache.set(name, parsed) + return parsed } __collect(element: Element, isRecursive: boolean): DirectiveMap { const map = new Map() if (!isHTMLElement(element)) return map - const prefixes = this.__prefixes - const processNode = (node: HTMLElement): void => { - const names = node - .getAttributeNames() - .filter((name) => prefixes.some((p) => name.startsWith(p))) - for (const name of names) { - if (!map.has(name)) map.set(name, new DirectiveElement(name)) - const item = map.get(name) as DirectiveElement + const prefixesByHead = this.__prefixesByHead + const appendDirective = (node: HTMLElement, name: string): void => { + const candidates = prefixesByHead.get(name[0] ?? '') + if (!candidates) return + for (let i = 0; i < candidates.length; ++i) { + if (!name.startsWith(candidates[i])) continue + let item = map.get(name) + if (!item) { + const parsed = this.__parseName(name) + item = new DirectiveElement(parsed.terms, parsed.flags) + map.set(name, item) + } item.__elements.push(node) + return + } + } + const processNode = (node: HTMLElement): void => { + const attrs = node.attributes + if (!attrs || attrs.length === 0) return + const attrsAny = attrs as any + for (let i = 0; i < attrs.length; ++i) { + const name = (attrsAny[i] ?? attrs.item(i))?.name + if (!name) continue + appendDirective(node, name) } } processNode(element) if (!isRecursive) return map + // IMPORTANT: + // Keep this on native `querySelectorAll('*')`. + // Browser engines optimize this traversal heavily and it is faster than + // handmade JS tree walking in real DOM benchmarks. + // Do not replace with custom recursion/stack traversal. const nodes = element.querySelectorAll('*') for (const node of nodes) { processNode(node) diff --git a/src/bind/DynamicBinder.ts b/src/bind/DynamicBinder.ts index 2fb388a..a59d7fa 100644 --- a/src/bind/DynamicBinder.ts +++ b/src/bind/DynamicBinder.ts @@ -9,7 +9,6 @@ import { unmount, } from '../common/common' import { isNullOrWhitespace, isObject, isString } from '../common/is-what' -import { observe } from '../observer/observe' import { type Binder } from './Binder' import { setSwitchOwner } from './switch' @@ -165,7 +164,9 @@ export class DynamicBinder { addUnbinder(commentBegin, unbinder) refresh() - const stopObserving = observe(value, refresh) + const stopObserving = ( + parseResult.subscribe ? parseResult.subscribe(refresh) : () => {} + ) as StopObserving stopObserverList.push(stopObserving) } } diff --git a/src/bind/ForBinder.ts b/src/bind/ForBinder.ts index 0ec9b99..cb7f305 100644 --- a/src/bind/ForBinder.ts +++ b/src/bind/ForBinder.ts @@ -10,7 +10,6 @@ import { } from '../common/common' import { isArray, isFunction, isNullOrUndefined } from '../common/is-what' import { warning, WarningType } from '../log/warnings' -import { observe } from '../observer/observe' import { sref } from '../reactivity/sref' import { unref } from '../reactivity/unref' import { type Binder } from './Binder' @@ -18,6 +17,7 @@ import { MountList } from './MountList' import { setSwitchOwner } from './switch' const forMarker = Symbol('r-for') +const noIndexRef = ((_: number) => -1) as SRef /** * @internal @@ -95,15 +95,26 @@ export class ForBinder { warning(WarningType.InvalidForExpression, this.__for, forPath, el) return } - // TODO: key binding requires expression evaluation on each step. - // For now, :key="id" is supported but :key="item.id" is not. const nameKey = this.__binder.__config.__builtInNames.key const nameKeyBind = this.__binder.__config.__builtInNames.keyBind - const key = el.getAttribute(nameKey) ?? el.getAttribute(nameKeyBind) + /** + * r-for key handling is path-based for speed. + * + * - `key` and `:key` are treated identically here. + * - The value is NOT evaluated as a full directive expression. + * - Supported key forms: + * - `id` (single property on current item) + * - dotted property paths (any depth), e.g. `row.id`, `row.meta.id`, `a.b.c.d` + * - For alias-like dotted keys (`row.id`), a fallback strips the first segment + * when needed so keys still resolve against the current item shape. + * - Not supported as key syntax: + * - arbitrary JS expressions (e.g. `row.id + '-' + row.type`) + * - function calls / computed expressions + */ + const keyExpression = + el.getAttribute(nameKey) ?? el.getAttribute(nameKeyBind) el.removeAttribute(nameKey) el.removeAttribute(nameKeyBind) - const getKey = key ? (v: any) => unref(unref(v)?.[key]) : (v: any) => v - const areEqual = (a: any, b: any): boolean => getKey(a) === getKey(b) const nodes = getNodes(el) const parent = el.parentNode as HTMLElement @@ -122,6 +133,12 @@ export class ForBinder { const binder = this.__binder const parser = binder.__parser const capturedContext = parser.__capture() + const singleCapturedContext = capturedContext.length === 1 + const rowContexts = singleCapturedContext + ? [undefined as any, capturedContext[0]] + : undefined + const getKey = this.__createKeyGetter(keyExpression) + const areEqual = (a: any, b: any): boolean => getKey(a) === getKey(b) const mountNewValue = ( i: number, newValue: any, @@ -129,17 +146,17 @@ export class ForBinder { ): MountListItem => { const result = config.createContext(newValue, i) const mountItem = MountList.__createItem(result.index, newValue) - parser.__scoped(capturedContext, () => { - parser.__push(result.ctx) + const renderInContext = (): void => { const insertParent = ((commentEnd.parentNode ?? commentBegin.parentNode) as HTMLElement | null) ?? parent let start = nextSibling.previousSibling as ChildNode const childNodes: ChildNode[] = [] - for (const x of nodes) { - const node = x.cloneNode(true) + // Perf improvement note: Do not try to create fragment to insert all nodes at once. It is not faster! + for (let j = 0; j < nodes.length; ++j) { + const node = nodes[j].cloneNode(true) as ChildNode insertParent.insertBefore(node, nextSibling) - childNodes.push(node as ChildNode) + childNodes.push(node) } bindChildNodes(binder, childNodes) start = start.nextSibling as ChildNode @@ -147,7 +164,16 @@ export class ForBinder { mountItem.items.push(start) start = start.nextSibling as ChildNode } - }) + } + if (rowContexts) { + rowContexts[0] = result.ctx + parser.__scoped(rowContexts, renderInContext) + } else { + parser.__scoped(capturedContext, () => { + parser.__push(result.ctx) + renderInContext() + }) + } return mountItem } @@ -266,7 +292,9 @@ export class ForBinder { } const observeTailChanges = (): void => { - stopObserving = observe(value, updateDom) + stopObserving = ( + parseResult.subscribe ? parseResult.subscribe(updateDom) : () => {} + ) as StopObserving } const unbinder = (): void => { @@ -336,7 +364,10 @@ export class ForBinder { ctx[k] = unrefValue[k] } } - const result = { ctx, index: sref(-1) } + const result: { ctx: any; index: SRef } = { + ctx, + index: noIndexRef, + } if (index) { result.index = ctx[ index.startsWith('#') ? index.substring(1) : index @@ -346,4 +377,38 @@ export class ForBinder { }, } } + + __createKeyGetter(keyExpression: string | null): (v: any) => unknown { + // Fast key resolver for r-for: + // - plain key: direct property lookup + // - dotted key: compiled path walk + // This intentionally avoids parser/evaluator overhead in hot diff paths. + if (!keyExpression) return (v: any) => v + const expression = keyExpression.trim() + if (!expression) return (v: any) => v + if (expression.includes('.')) { + const path = this.__compilePath(expression) + const fallbackPath = path.length > 1 ? path.slice(1) : undefined + return (v: any) => { + const base = unref(v) + const primary = this.__walkPath(base, path) + if (primary !== undefined || !fallbackPath) return primary + // Supports alias-prefixed keys like "row.id" without carrying alias metadata. + return this.__walkPath(base, fallbackPath) + } + } + return (v: any) => unref(unref(v)?.[expression]) + } + + __compilePath(path: string): string[] { + return path.split('.').filter((x) => x.length > 0) + } + + __walkPath(source: any, path: string[]): unknown { + let value = source + for (const part of path) { + value = unref(value)?.[part] + } + return unref(value) + } } diff --git a/src/bind/IfBinder.ts b/src/bind/IfBinder.ts index 586955a..87591b9 100644 --- a/src/bind/IfBinder.ts +++ b/src/bind/IfBinder.ts @@ -9,7 +9,6 @@ import { unmount, } from '../common/common' import { warning, WarningType } from '../log/warnings' -import { observe } from '../observer/observe' import { type Binder } from './Binder' import { setSwitchOwner } from './switch' @@ -193,7 +192,9 @@ export class IfBinder { } addUnbinder(commentBegin, unbinder) - const stopObserving = observe(value, refresh) + const stopObserving = ( + parseResult.subscribe ? parseResult.subscribe(refresh) : () => {} + ) as StopObserving stopObserverList.push(stopObserving) return [ @@ -268,7 +269,9 @@ export class IfBinder { addUnbinder(commentBegin, unbinder) refresh() - const stopObserving = observe(value, refresh) + const stopObserving = ( + parseResult.subscribe ? parseResult.subscribe(refresh) : () => {} + ) as StopObserving stopObserverList.push(stopObserving) } } diff --git a/src/cleanup/removeNode.ts b/src/cleanup/removeNode.ts index 46ebc3b..4860fd1 100644 --- a/src/cleanup/removeNode.ts +++ b/src/cleanup/removeNode.ts @@ -1,6 +1,30 @@ import { unbind } from './unbind' +const pendingUnbind: ChildNode[] = [] +let flushScheduled = false +let flushTimer: ReturnType | undefined + +const flushPendingUnbind = (): void => { + flushScheduled = false + flushTimer = undefined + if (pendingUnbind.length === 0) return + for (let i = 0; i < pendingUnbind.length; ++i) { + unbind(pendingUnbind[i]) + } + pendingUnbind.length = 0 +} + export const removeNode = (node: ChildNode): void => { node.remove() - setTimeout(() => unbind(node), 1) + pendingUnbind.push(node) + if (!flushScheduled) { + flushScheduled = true + flushTimer = setTimeout(flushPendingUnbind, 1) + } +} + +export const drainUnbind = async (): Promise => { + if (pendingUnbind.length === 0 && !flushScheduled) return + if (flushTimer) clearTimeout(flushTimer) + flushPendingUnbind() } diff --git a/src/cleanup/unbind.ts b/src/cleanup/unbind.ts index 59de9c1..26f6c24 100644 --- a/src/cleanup/unbind.ts +++ b/src/cleanup/unbind.ts @@ -1,15 +1,16 @@ import { bindDataSymbol } from './bindDataSymbol' export const unbind = (node: Node): void => { - const queue: any[] = [node] - - while (queue.length > 0) { - const currentElement = queue.shift() - unbindSingle(currentElement) - const childNodes = currentElement.childNodes - if (!childNodes) continue - for (const item of childNodes) { - queue.push(item) + const stack: Node[] = [node] + while (stack.length > 0) { + const currentNode = stack.pop() as Node + unbindSingle(currentNode) + for ( + let child = currentNode.lastChild; + child != null; + child = child.previousSibling + ) { + stack.push(child) } } } @@ -17,10 +18,10 @@ export const unbind = (node: Node): void => { const unbindSingle = (node: Node): void => { const bindData = (node as any)[bindDataSymbol] if (!bindData) return - for (const unbinder of bindData.unbinders) { - unbinder() + const unbinders = bindData.unbinders + for (let i = 0; i < unbinders.length; ++i) { + unbinders[i]() } - bindData.unbinders.splice(0) - - delete (node as any)[bindDataSymbol] + unbinders.length = 0 + ;(node as any)[bindDataSymbol] = undefined } diff --git a/src/common/common.ts b/src/common/common.ts index 41b71ca..8dbd0db 100644 --- a/src/common/common.ts +++ b/src/common/common.ts @@ -22,7 +22,9 @@ export const bindChildNodes = ( binder: Binder, childNodes: ChildNode[], ): void => { - for (const child of childNodes) { + for (let i = 0; i < childNodes.length; ++i) { + const child = childNodes[i] + if (child.nodeType !== Node.ELEMENT_NODE) continue // r-if binding can remove sibling else nodes if (!isElseNode(child)) binder.__bindDefault(child as Element) } @@ -84,21 +86,44 @@ export const unmount = (start: Node, end: Node): void => { } } +/** + * @internal + */ +const getRefValue = function (this: AnyRef): unknown { + return this() +} + +const setRefValue = function (this: AnyRef, value: unknown): unknown { + return this(value) +} + +const setReadonlyRefValue = (): never => { + throw new Error('value is readonly.') +} + +const writableRefValueDescriptor: PropertyDescriptor = { + get: getRefValue as () => unknown, + set: setRefValue as (value: unknown) => unknown, + enumerable: true, + configurable: false, +} + +const readonlyRefValueDescriptor: PropertyDescriptor = { + get: getRefValue as () => unknown, + set: setReadonlyRefValue as (value: unknown) => never, + enumerable: true, + configurable: false, +} + /** * @internal */ export const defineRefValue = (result: AnyRef, isReadOnly: boolean): void => { - Object.defineProperty(result, 'value', { - get() { - return result() - }, - set(value: unknown) { - if (isReadOnly) throw new Error('value is readonly.') - return result(value) - }, - enumerable: true, - configurable: false, - }) + Object.defineProperty( + result, + 'value', + isReadOnly ? readonlyRefValueDescriptor : writableRefValueDescriptor, + ) } /** diff --git a/src/common/looseEqual.ts b/src/common/looseEqual.ts index 2cc9554..c6dfba0 100644 --- a/src/common/looseEqual.ts +++ b/src/common/looseEqual.ts @@ -37,28 +37,20 @@ export function looseEqual(a: any, b: any): boolean { aValidType = isObject(a) bValidType = isObject(b) if (aValidType || bValidType) { - /* istanbul ignore if: this if will probably never be called */ - if (!aValidType || !bValidType) { - return false - } + if (!aValidType || !bValidType) return false const aKeysCount = Object.keys(a).length const bKeysCount = Object.keys(b).length if (aKeysCount !== bKeysCount) { return false } for (const key in a) { - // eslint-disable-next-line no-prototype-builtins - const aHasKey = a.hasOwnProperty(key) - // eslint-disable-next-line no-prototype-builtins - const bHasKey = b.hasOwnProperty(key) - if ( - (aHasKey && !bHasKey) || - (!aHasKey && bHasKey) || - !looseEqual(a[key], b[key]) - ) { + const aHasKey = Object.prototype.hasOwnProperty.call(a, key) + const bHasKey = Object.prototype.hasOwnProperty.call(b, key) + if ((aHasKey && !bHasKey) || !looseEqual(a[key], b[key])) { return false } } + return true } return String(a) === String(b) } diff --git a/src/directives/attr.ts b/src/directives/attr.ts index d5ef188..45811dd 100644 --- a/src/directives/attr.ts +++ b/src/directives/attr.ts @@ -101,7 +101,7 @@ const patchAttribute = ( } if (isNullOrUndefined(key)) { - warning(WarningType.KeyIsEmpty, name, el) + warning(WarningType.KeyIsEmpty, 'r-bind', el) return } diff --git a/src/directives/on.ts b/src/directives/on.ts index 66ea606..0b9fce6 100644 --- a/src/directives/on.ts +++ b/src/directives/on.ts @@ -94,7 +94,7 @@ export const onDirective: Directive = { unbinders.push(attachEventListener(el, eventType, method, flags)) } } else { - warning(WarningType.BindingRequiresObjectExpressions, name, el) + warning(WarningType.BindingRequiresObjectExpressions, 'r-on', el) } } return unbinder @@ -150,7 +150,7 @@ export const attachEventListener = ( flags: any, ): Unbinder => { if (isNullOrWhitespace(eventType)) { - warning(WarningType.MissingEventType, name, el) + warning(WarningType.MissingEventType, 'r-on', el) return () => {} } diff --git a/src/directives/prop.ts b/src/directives/prop.ts index a42c158..1b481c0 100644 --- a/src/directives/prop.ts +++ b/src/directives/prop.ts @@ -60,7 +60,7 @@ function includeBooleanAttr(value: unknown): boolean { */ export const patchProp = (el: any, key: string, value: any): void => { if (isNullOrUndefined(key)) { - warning(WarningType.KeyIsEmpty, name, el) + warning(WarningType.KeyIsEmpty, ':prop', el) return } diff --git a/src/directives/single-prop.ts b/src/directives/single-prop.ts index 754b663..66c91be 100644 --- a/src/directives/single-prop.ts +++ b/src/directives/single-prop.ts @@ -36,6 +36,7 @@ import { ref } from '../reactivity/ref' * - Preserves nested propagation while avoiding previous-source overwrite. */ const modelBridgeSymbol = Symbol('modelBridge') +const noop: Unbinder = () => {} const isModelBridge = (value: unknown): boolean => !!(value as Record)?.[modelBridgeSymbol] const markModelBridge = (value: AnyRef): void => { @@ -60,21 +61,21 @@ export const singlePropDirective: Directive = { _dynamicOption?: ParseResult, _flags?: string[], ): Unbinder => { - if (!option) return () => {} + if (!option) return noop const key = camelize(option) let currentSource: AnyRef | undefined let bridge: AnyRef | undefined - let stopEntangle: Unbinder = () => {} + let stopEntangle: Unbinder = noop const resetSync = (): void => { stopEntangle() - stopEntangle = () => {} + stopEntangle = noop currentSource = undefined bridge = undefined } const clearEntangle = (): void => { stopEntangle() - stopEntangle = () => {} + stopEntangle = noop } const syncRefs = (source: AnyRef, target: AnyRef): void => { if (currentSource === source) return diff --git a/src/directives/value.ts b/src/directives/value.ts index 3ce106e..a08d828 100644 --- a/src/directives/value.ts +++ b/src/directives/value.ts @@ -5,7 +5,7 @@ import { patchProp } from './prop' * @internal */ export const valueDirective: Directive = { - onChange: (el: HTMLElement, values: any[]) => { + onChange: (el: HTMLElement, values: unknown[]) => { patchProp(el, 'value', values[0]) }, } diff --git a/src/index.ts b/src/index.ts index 34359e4..0444799 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,6 +30,7 @@ export { toFragment } from './app/toFragment' export { toJsonTemplate } from './app/toJsonTemplate' export { addUnbinder } from './cleanup/addUnbinder' export { getBindData } from './cleanup/getBindData' +export { drainUnbind } from './cleanup/removeNode' export { removeNode } from './cleanup/removeNode' export { unbind } from './cleanup/unbind' export { onMounted } from './composition/onMounted' diff --git a/src/observer/observe.ts b/src/observer/observe.ts index 76cb60b..3ce48d5 100644 --- a/src/observer/observe.ts +++ b/src/observer/observe.ts @@ -14,6 +14,7 @@ export const observe = ( source: TValueType, observer: ObserveCallback>, init?: boolean, + trackUnmount = true, ): StopObserving => { if (!isRef(source)) throw getError(ErrorType.RequiresRefSourceArgument, 'observe') @@ -25,6 +26,6 @@ export const observe = ( RefOperation.observe, observer, ) as StopObserving - onUnmounted(stop, true) + if (trackUnmount) onUnmounted(stop, true) return stop } diff --git a/src/parser/Parser.ts b/src/parser/Parser.ts index b36df42..235b4d9 100644 --- a/src/parser/Parser.ts +++ b/src/parser/Parser.ts @@ -74,21 +74,38 @@ export class Parser { ): ParseResult { const value = sref([]) const stopObserverList = [] as StopObserving[] - const unbinder = (): void => { + const subscribers = new Set<(values: unknown[]) => void>() + const clearObservers = (): void => { for (const stopObserver of stopObserverList) { stopObserver() } stopObserverList.length = 0 } + const unbinder = (): void => { + clearObservers() + subscribers.clear() + } + const subscribe = ( + observer: (values: unknown[]) => void, + init?: boolean, + ): StopObserving => { + subscribers.add(observer) + if (init) observer(value() as unknown[]) + return () => { + subscribers.delete(observer) + } + } const result: ParseResult = { value, stop: unbinder, + subscribe, refs: [], context: this.__contexts[0], } if (isNullOrWhitespace(expression)) return result const globalContext = this.__config.globalContext - const refs: any[] = [] + const refs: AnyRef[] = [] + const uniqueRefs = new Set() const evaluate = ( expr: Expression, contexts: any[], @@ -117,27 +134,42 @@ export class Parser { astCache[expression] ?? (jsep('[' + expression + ']') as ArrayExpression) astCache[expression] = ast - const contexts = this.__contexts + const contexts = this.__contexts.slice() + const elements = ast.elements + const len = elements.length const refresh = (): void => { - refs.splice(0) - unbinder() - const evaluated = ast.elements.map((x, i) => { - if (isLazy?.(i, -1)) - return { - value: (e: Event) => - evaluate(x, contexts, false, { $event: e }).value, - refs: [], - } - return evaluate(x, contexts, true) - }) + refs.length = 0 + uniqueRefs.clear() + clearObservers() + const values = new Array(len) + const expressionRefs = new Array(len) + for (let i = 0; i < len; ++i) { + const expr = elements[i] + if (isLazy?.(i, -1)) { + values[i] = (e: Event) => + evaluate(expr, contexts, false, { $event: e }).value + continue + } + const evaluated = evaluate(expr, contexts, true) + values[i] = evaluated.value + expressionRefs[i] = evaluated.ref + } if (!once) { for (const r of refs) { + if (uniqueRefs.has(r)) continue + uniqueRefs.add(r) const stopObserving = observe(r, refresh) stopObserverList.push(stopObserving) } } - result.refs = evaluated.map((x) => x.ref) - value(evaluated.map((x) => x.value)) + result.refs = expressionRefs + value(values) + if (subscribers.size !== 0) { + for (const subscriber of subscribers) { + if (!subscribers.has(subscriber)) continue + subscriber(values) + } + } } refresh() } catch (e) { @@ -147,7 +179,7 @@ export class Parser { } __capture(): any[] { - return this.__contexts + return this.__contexts.slice() } __replaced: any[][] = [] diff --git a/src/parser/jsep/jsep.ts b/src/parser/jsep/jsep.ts index 2db762b..fb1fa0c 100644 --- a/src/parser/jsep/jsep.ts +++ b/src/parser/jsep/jsep.ts @@ -3,13 +3,22 @@ // Copyright (c) 2013 Stephen Oney, https://ericsmekens.github.io/jsep/ // Modifications: // - removed plugin code -// - include required plugins directly in main Jsep classs. +// - include required plugin behavior directly in the main Jsep class // - converted javascript to typescript // - converted expression type from string to enum // - reduced code size +// - optimized for parser throughput: +// - removed dynamic hook dispatch from hot paths +// - parse arrow/assignment nodes directly (no AST-wide fixup passes) +// - replaced substring operator scans with char-code matching +// - reduced per-token allocations in prefix/postfix parsing +// - switched numeric/string/template literal scanning to index-based parsing +// with late materialization to reduce string concatenation allocations import type { ArrayExpression, + ArrowExpression, + AssignmentExpression, BinaryExpression, Compound, Expression, @@ -18,7 +27,7 @@ import type { TemplateLiteral, UnaryExpression, } from './jsep-types' -import { ExpressionType, HookType } from './jsep-types' +import { ExpressionType } from './jsep-types' const TAB_CODE = 9 const LF_CODE = 10 @@ -43,16 +52,10 @@ const BTICK_CODE = 96 // ` const FSLASH_CODE = 47 // '/' const BSLASH_CODE = 92 // '\\' -const updateNodeTypes = [ExpressionType.Identifier, ExpressionType.Member] -const updateOperators = [PLUS_CODE, MINUS_CODE] - -const unaryOps = { - '-': 1, - '!': 1, - '~': 1, - '+': 1, - new: 1, -} +const updateNodeTypes = new Set([ + ExpressionType.Identifier, + ExpressionType.Member, +]) const assignmentOps = { '=': 2.5, @@ -102,10 +105,8 @@ const binaryOps = { const assignmentOperatorKeys = Object.keys(assignmentOps) const assigmentOperatorsSet = new Set(assignmentOperatorKeys) -const rightAssociative = new Set() -rightAssociative.add('=>') +const rightAssociative = new Set(['=>']) assignmentOperatorKeys.forEach((x) => rightAssociative.add(x as BinaryOperator)) -const additionalIdentifierChars = new Set(['$', '_']) const literals = { true: true, @@ -115,14 +116,6 @@ const literals = { const thisStr = 'this' -function getMaxKeyLen(obj: any): number { - return Math.max(0, ...Object.keys(obj).map((k) => k.length)) -} - -const maxUnopLen = getMaxKeyLen(unaryOps) - -const maxBinopLen = getMaxKeyLen(binaryOps) - interface HookResult { node?: Expression | false } @@ -135,7 +128,7 @@ export type BinaryOperator = keyof typeof binaryOps /** * @internal */ -export type UnaryOperator = keyof typeof unaryOps +export type UnaryOperator = '-' | '!' | '~' | '+' | 'new' /** * @internal @@ -163,44 +156,15 @@ const msgVariablesCannotStartWithANumber = 'Variable names cannot start with a number (' const msgUnclosedQuoteAfter = msgUnclosed + 'quote after "' -enum HookCallType { - All, - Single, -} - const isDecimalDigit = (ch: number): boolean => { return ch >= 48 && ch <= 57 // 0...9 } const binaryPrecedence = (opVal: BinaryOperator): number => { - return binaryOps[opVal] || 0 + return binaryOps[opVal] } -type HookFunction = (env: HookResult) => void class Jsep { - __hooks: { - [K in HookType]: HookFunction[] - } = { - [HookType.gobbleExpression]: [this.__gobbleEmptyArrowArg], - [HookType.afterExpression]: [ - this.__fixBinaryArrow, - this.__fixAssignmentOperators, - this.__gobbleTernary, - ], - [HookType.gobbleToken]: [ - this.__gobbleObjectExpression, - this.__gobbleUpdatePrefix, - this.__gobbleSpread, - this.__gobbleTemplateLiteral, - this.__gobbleRegexLiteral, - ], - [HookType.afterToken]: [ - this.__gobbleNew, - this.__gobbleUpdatePostfix, - this.__gobbleTaggedTemplateIdentifier, - ], - } - __expr: string __index: number @@ -223,14 +187,13 @@ class Jsep { } __isIdentifierStart(ch: number): boolean { - const char = String.fromCharCode(ch) return ( (ch >= 65 && ch <= 90) || // A...Z (ch >= 97 && ch <= 122) || // a...z - (ch >= 128 && !(char in binaryOps)) || - // any non-ASCII that is not an operator - additionalIdentifierChars.has(char) - ) // additional characters + ch === 36 || // $ + ch === 95 || // _ + ch >= 128 + ) } __isIdentifierPart(ch: number): boolean { @@ -241,22 +204,6 @@ class Jsep { return new Error(`${message} at character ${this.__index}`) } - __runHook( - hookType: HookType, - hookCalltype: HookCallType, - node?: Expression | false, - ): GobbleResult { - const hook = this.__hooks[hookType] - if (!hook) return node - const env: HookResult = { node } - const hookFn = (f: HookFunction): void => { - f.call(this, env) - } - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - hookCalltype === HookCallType.All ? hook.forEach(hookFn) : hook.find(hookFn) - return env.node - } - __gobbleSpaces(): void { let ch = this.__code const expr = this.__expr @@ -311,10 +258,9 @@ class Jsep { __gobbleExpression(): GobbleResult { const node = - this.__runHook(HookType.gobbleExpression, HookCallType.Single) ?? - this.__gobbleBinaryExpression() + this.__gobbleEmptyArrowArgNode() ?? this.__gobbleBinaryExpression() this.__gobbleSpaces() - return this.__runHook(HookType.afterExpression, HookCallType.All, node) + return this.__gobbleTernaryNode(node) } /** @@ -325,28 +271,139 @@ class Jsep { */ __gobbleBinaryOp(): false | BinaryOperator { this.__gobbleSpaces() - let index = this.__index const expr = this.__expr - let toCheck = expr.substr(index, maxBinopLen) - let tcLen = toCheck.length - - while (tcLen > 0) { - // Don't accept a binary op when it is an identifier. - // Binary ops that start with a identifier-valid character must be followed - // by a non identifier-part valid character - if ( - toCheck in binaryOps && - (!this.__isIdentifierStart(this.__code) || - (index + toCheck.length < expr.length && - !this.__isIdentifierPart(expr.charCodeAt(index + toCheck.length)))) - ) { - index += tcLen - this.__index = index - return toCheck as BinaryOperator + const index = this.__index + const c1 = expr.charCodeAt(index) + const c2 = expr.charCodeAt(index + 1) + const c3 = expr.charCodeAt(index + 2) + const c4 = expr.charCodeAt(index + 3) + + if (isNaN(c1)) return false + + let op: BinaryOperator | false = false + let opLen = 0 + + if (c1 === 62 && c2 === 62 && c3 === 62 && c4 === 61) { + op = '>>>=' + opLen = 4 + } else if (c1 === 61 && c2 === 61 && c3 === 61) { + op = '===' + opLen = 3 + } else if (c1 === 33 && c2 === 61 && c3 === 61) { + op = '!==' + opLen = 3 + } else if (c1 === 62 && c2 === 62 && c3 === 62) { + op = '>>>' + opLen = 3 + } else if (c1 === 60 && c2 === 60 && c3 === 61) { + op = '<<=' + opLen = 3 + } else if (c1 === 62 && c2 === 62 && c3 === 61) { + op = '>>=' + opLen = 3 + } else if (c1 === 42 && c2 === 42 && c3 === 61) { + op = '**=' + opLen = 3 + } else if (c1 === 61 && c2 === 62) { + op = '=>' + opLen = 2 + } else if (c1 === 124 && c2 === 124) { + op = '||' + opLen = 2 + } else if (c1 === 63 && c2 === 63) { + op = '??' + opLen = 2 + } else if (c1 === 38 && c2 === 38) { + op = '&&' + opLen = 2 + } else if (c1 === 61 && c2 === 61) { + op = '==' + opLen = 2 + } else if (c1 === 33 && c2 === 61) { + op = '!=' + opLen = 2 + } else if (c1 === 60 && c2 === 61) { + op = '<=' + opLen = 2 + } else if (c1 === 62 && c2 === 61) { + op = '>=' + opLen = 2 + } else if (c1 === 60 && c2 === 60) { + op = '<<' + opLen = 2 + } else if (c1 === 62 && c2 === 62) { + op = '>>' + opLen = 2 + } else if (c1 === 43 && c2 === 61) { + op = '+=' + opLen = 2 + } else if (c1 === 45 && c2 === 61) { + op = '-=' + opLen = 2 + } else if (c1 === 42 && c2 === 61) { + op = '*=' + opLen = 2 + } else if (c1 === 47 && c2 === 61) { + op = '/=' + opLen = 2 + } else if (c1 === 37 && c2 === 61) { + op = '%=' + opLen = 2 + } else if (c1 === 38 && c2 === 61) { + op = '&=' + opLen = 2 + } else if (c1 === 94 && c2 === 61) { + op = '^=' + opLen = 2 + } else if (c1 === 124 && c2 === 61) { + op = '|=' + opLen = 2 + } else if (c1 === 42 && c2 === 42) { + op = '**' + opLen = 2 + } else if (c1 === 105 && c2 === 110) { + if (!this.__isIdentifierPart(expr.charCodeAt(index + 2))) { + op = 'in' + opLen = 2 } - toCheck = toCheck.substr(0, --tcLen) + } else if (c1 === 61) { + op = '=' + opLen = 1 + } else if (c1 === 124) { + op = '|' + opLen = 1 + } else if (c1 === 94) { + op = '^' + opLen = 1 + } else if (c1 === 38) { + op = '&' + opLen = 1 + } else if (c1 === 60) { + op = '<' + opLen = 1 + } else if (c1 === 62) { + op = '>' + opLen = 1 + } else if (c1 === 43) { + op = '+' + opLen = 1 + } else if (c1 === 45) { + op = '-' + opLen = 1 + } else if (c1 === 42) { + op = '*' + opLen = 1 + } else if (c1 === 47) { + op = '/' + opLen = 1 + } else if (c1 === 37) { + op = '%' + opLen = 1 } - return false + + if (!op) return false + this.__index += opLen + return op } /** @@ -358,7 +415,7 @@ class Jsep { biop: BinaryOperator | false, prec: number, biopInfo: { - value: BinaryOperator | false + value: BinaryOperator prec: number right_a: boolean }, @@ -401,11 +458,6 @@ class Jsep { while ((biop = this.__gobbleBinaryOp())) { prec = binaryPrecedence(biop) - if (prec === 0) { - this.__index -= biop.length - break - } - biopInfo = { value: biop, prec, @@ -425,12 +477,11 @@ class Jsep { right = stack.pop() biop = (stack.pop() as typeof biopInfo).value left = stack.pop() - node = { - type: ExpressionType.Binary, - operator: biop, - left, - right, - } + node = this.__createBinaryNode( + biop as BinaryOperator, + left as Expression, + right as Expression, + ) stack.push(node) } @@ -447,12 +498,11 @@ class Jsep { node = stack[i] as GobbleResult while (i > 1) { - node = { - type: ExpressionType.Binary, - operator: (stack[i - 1] as typeof biopInfo).value, - left: stack[i - 2], - right: node, - } + node = this.__createBinaryNode( + (stack[i - 1] as typeof biopInfo).value as BinaryOperator, + stack[i - 2] as Expression, + node as Expression, + ) i -= 2 } @@ -464,13 +514,10 @@ class Jsep { * e.g. `foo.bar(baz)`, `1`, `"abc"`, `(a % 2)` (because it's in parenthesis) */ __gobbleToken(): false | Expression | undefined { - let toCheck, tcLen, node: GobbleResult - + let node: GobbleResult this.__gobbleSpaces() - node = this.__runHook(HookType.gobbleToken, HookCallType.Single) - if (node) { - return this.__runHook(HookType.afterToken, HookCallType.All, node) - } + node = this.__gobbleTokenFromPrefix() + if (node) return this.__postProcessToken(node) const ch = this.__code @@ -485,34 +532,17 @@ class Jsep { } else if (ch === OBRACK_CODE) { node = this.__gobbleArray() } else { - toCheck = this.__expr.substr(this.__index, maxUnopLen) - tcLen = toCheck.length - - while (tcLen > 0) { - // Don't accept an unary op when it is an identifier. - // Unary ops that start with a identifier-valid character must be followed - // by a non identifier-part valid character - if ( - Object.prototype.hasOwnProperty.call(unaryOps, toCheck) && - (!this.__isIdentifierStart(this.__code) || - (this.__index + toCheck.length < this.__expr.length && - !this.__isIdentifierPart( - this.__expr.charCodeAt(this.__index + toCheck.length), - ))) - ) { - this.__index += tcLen - const argument = this.__gobbleToken() - if (!argument) { - throw this.__getError(msgMissingUnaryOp) - } - return this.__runHook(HookType.afterToken, HookCallType.All, { - type: ExpressionType.Unary, - operator: toCheck, - argument, - }) + const unary = this.__gobbleUnaryOp() + if (unary) { + const argument = this.__gobbleToken() + if (!argument) { + throw this.__getError(msgMissingUnaryOp) } - - toCheck = toCheck.substr(0, --tcLen) + return this.__postProcessToken({ + type: ExpressionType.Unary, + operator: unary, + argument, + }) } if (this.__isIdentifierStart(ch)) { @@ -533,11 +563,115 @@ class Jsep { } if (!node) { - return this.__runHook(HookType.afterToken, HookCallType.All, false) + return false } node = this.__gobbleTokenProperty(node) - return this.__runHook(HookType.afterToken, HookCallType.All, node) + return this.__postProcessToken(node) + } + + __createBinaryNode( + operator: BinaryOperator, + left: Expression, + right: Expression, + ): BinaryExpression | AssignmentExpression | ArrowExpression { + if (operator === '=>') { + const params = + left.type === ExpressionType.Sequence + ? (left.expressions as any) + : [left] + return { + type: ExpressionType.Arrow, + params, + body: right, + } + } + + if (assigmentOperatorsSet.has(operator)) { + return { + type: ExpressionType.Assignment, + operator: operator as AssignmentOperator, + left, + right, + } + } + + return { + type: ExpressionType.Binary, + operator, + left, + right, + } + } + + __gobbleTokenFromPrefix(): GobbleResult { + const env: HookResult = { node: false } + this.__gobbleObjectExpression(env) + if (env.node) return env.node + this.__gobbleUpdatePrefix(env) + if (env.node) return env.node + this.__gobbleSpread(env) + if (env.node) return env.node + this.__gobbleTemplateLiteral(env) + if (env.node) return env.node + this.__gobbleRegexLiteral(env) + return env.node + } + + __postProcessToken(node: Expression): Expression { + const env: HookResult = { node } + this.__gobbleNew(env) + this.__gobbleUpdatePostfix(env) + this.__gobbleTaggedTemplateIdentifier(env) + return env.node as Expression + } + + __gobbleUnaryOp(): UnaryOperator | false { + const expr = this.__expr + const index = this.__index + const c1 = expr.charCodeAt(index) + const c2 = expr.charCodeAt(index + 1) + const c3 = expr.charCodeAt(index + 2) + const c4 = expr.charCodeAt(index + 3) + + if (c1 === MINUS_CODE) { + this.__index++ + return '-' + } + if (c1 === 33) { + this.__index++ + return '!' + } + if (c1 === 126) { + this.__index++ + return '~' + } + if (c1 === PLUS_CODE) { + this.__index++ + return '+' + } + if ( + c1 === 110 && + c2 === 101 && + c3 === 119 && + !this.__isIdentifierPart(c4) + ) { + this.__index += 3 + return 'new' + } + return false + } + + __gobbleEmptyArrowArgNode(): GobbleResult { + const env: HookResult = {} + this.__gobbleEmptyArrowArg(env) + return env.node + } + + __gobbleTernaryNode(node: GobbleResult): GobbleResult { + const env: HookResult = { node } + this.__gobbleTernary(env) + return env.node } /** @@ -588,7 +722,7 @@ class Jsep { arguments: this.__gobbleArguments(CPAREN_CODE), callee: node, } - } else if (ch === PERIOD_CODE || optional) { + } else { if (optional) { this.__index-- } @@ -617,45 +751,46 @@ class Jsep { * keep track of everything in the numeric literal and then calling `parseFloat` on that string */ __gobbleNumericLiteral(): Literal { - let number = '' - let ch + const expr = this.__expr + const start = this.__index + let index = start - while (isDecimalDigit(this.__code)) { - number += this.__expr.charAt(this.__index++) + while (isDecimalDigit(expr.charCodeAt(index))) { + index++ } - if (this.__isCode(PERIOD_CODE)) { + if (expr.charCodeAt(index) === PERIOD_CODE) { // can start with a decimal marker - number += this.__expr.charAt(this.__index++) - - while (isDecimalDigit(this.__code)) { - number += this.__expr.charAt(this.__index++) + index++ + while (isDecimalDigit(expr.charCodeAt(index))) { + index++ } } - ch = this.__char - - if (ch === 'e' || ch === 'E') { + const exponentCode = expr.charCodeAt(index) + if (exponentCode === 101 || exponentCode === 69) { // exponent marker - number += this.__expr.charAt(this.__index++) - ch = this.__char - - if (ch === '+' || ch === '-') { + index++ + const signCode = expr.charCodeAt(index) + if (signCode === PLUS_CODE || signCode === MINUS_CODE) { // exponent sign - number += this.__expr.charAt(this.__index++) + index++ } - - while (isDecimalDigit(this.__code)) { + const exponentStart = index + while (isDecimalDigit(expr.charCodeAt(index))) { // exponent itself - number += this.__expr.charAt(this.__index++) + index++ } - - if (!isDecimalDigit(this.__expr.charCodeAt(this.__index - 1))) { + if (exponentStart === index) { + this.__index = index + const number = expr.slice(start, index) throw this.__getError(msgExpectedExponent + number + this.__char + ')') } } - const chCode = this.__code + this.__index = index + const number = expr.slice(start, index) + const chCode = expr.charCodeAt(index) // Check to make sure this isn't a variable name that start with a number (123abc) if (this.__isIdentifierStart(chCode)) { @@ -681,56 +816,70 @@ class Jsep { * e.g. `"hello world"`, `'this is\nJSEP'` */ __gobbleStringLiteral(): Literal { - let str = '' + const expr = this.__expr + const length = expr.length const startIndex = this.__index - const quote = this.__expr.charAt(this.__index++) + const quoteCode = expr.charCodeAt(this.__index++) + let index = this.__index + let segmentStart = index + const cookedParts: string[] = [] + let hadEscape = false let closed = false - while (this.__index < this.__expr.length) { - let ch = this.__expr.charAt(this.__index++) - - if (ch === quote) { + while (index < length) { + const code = expr.charCodeAt(index) + if (code === quoteCode) { closed = true + this.__index = index + 1 break - } else if (ch === '\\') { - // Check for all of the common escape codes - ch = this.__expr.charAt(this.__index++) - - switch (ch) { - case 'n': - str += '\n' - break - case 'r': - str += '\r' - break - case 't': - str += '\t' - break - case 'b': - str += '\b' - break - case 'f': - str += '\f' - break - case 'v': - str += '\x0B' - break - default: - str += ch - } + } + if (code === BSLASH_CODE) { + if (!hadEscape) hadEscape = true + cookedParts.push(expr.slice(segmentStart, index)) + const escCode = expr.charCodeAt(index + 1) + cookedParts.push(this.__decodeEscapedChar(escCode)) + index += 2 + segmentStart = index } else { - str += ch + index++ } } + const str = hadEscape + ? cookedParts.join('') + expr.slice(segmentStart, closed ? index : length) + : expr.slice(segmentStart, closed ? index : length) + if (!closed) { + this.__index = index throw this.__getError(msgUnclosedQuoteAfter + str + '"') } return { type: ExpressionType.Literal, value: str, - raw: this.__expr.substring(startIndex, this.__index), + raw: expr.substring(startIndex, this.__index), + } + } + + __decodeEscapedChar(code: number): string { + switch (code) { + case 110: + return '\n' + case 114: + return '\r' + case 116: + return '\t' + case 98: + return '\b' + case 102: + return '\f' + case 118: + return '\x0B' + default: + if (isNaN(code)) { + return '' + } + return String.fromCharCode(code) } } @@ -806,7 +955,7 @@ class Jsep { // missing argument if (termination === CPAREN_CODE) { throw this.__getError(msgUnexpectedToken + ',') - } else if (termination === CBRACK_CODE) { + } else { for (let arg = args.length; arg < separatorCount; arg++) { args.push(null) } @@ -926,7 +1075,7 @@ class Jsep { shorthand: false, }) this.__gobbleSpaces() - } else if (key) { + } else { // spread, assignment (object destructuring with defaults), etc. properties.push(key) } @@ -942,9 +1091,8 @@ class Jsep { __gobbleUpdatePrefix(env: HookResult): void { const code = this.__code if ( - updateOperators.some( - (c) => c === code && c === this.__expr.charCodeAt(this.__index + 1), - ) + (code === PLUS_CODE || code === MINUS_CODE) && + code === this.__expr.charCodeAt(this.__index + 1) ) { this.__index += 2 const expr = (env.node = { @@ -953,39 +1101,39 @@ class Jsep { argument: this.__gobbleTokenProperty(this.__gobbleIdentifier()), prefix: true, }) - if (!expr.argument || !updateNodeTypes.includes(expr.argument.type)) { + if (!expr.argument || !updateNodeTypes.has(expr.argument.type)) { throw this.__getError(msgUnexpected + expr.operator) } } } __gobbleUpdatePostfix(env: HookResult): void { - if (env.node) { - const code = this.__code - if ( - updateOperators.some( - (c) => c === code && c === this.__expr.charCodeAt(this.__index + 1), + const node = env.node as Expression + const code = this.__code + if ( + (code === PLUS_CODE || code === MINUS_CODE) && + code === this.__expr.charCodeAt(this.__index + 1) + ) { + if (!updateNodeTypes.has(node.type)) { + throw this.__getError( + msgUnexpected + (code === PLUS_CODE ? '++' : '--'), ) - ) { - if (!updateNodeTypes.includes(env.node.type)) { - throw this.__getError(msgUnexpected + env.node.operator) - } - this.__index += 2 - env.node = { - type: ExpressionType.Update, - operator: code === PLUS_CODE ? '++' : '--', - argument: env.node, - prefix: false, - } + } + this.__index += 2 + env.node = { + type: ExpressionType.Update, + operator: code === PLUS_CODE ? '++' : '--', + argument: node, + prefix: false, } } } __gobbleSpread(env: HookResult): void { if ( - [0, 1, 2].every( - (i) => this.__expr.charCodeAt(this.__index + i) === PERIOD_CODE, - ) + this.__expr.charCodeAt(this.__index) === PERIOD_CODE && + this.__expr.charCodeAt(this.__index + 1) === PERIOD_CODE && + this.__expr.charCodeAt(this.__index + 2) === PERIOD_CODE ) { this.__index += 3 env.node = { @@ -1020,24 +1168,6 @@ class Jsep { consequent, alternate, } - - // check for operators of higher priority than ternary (i.e. assignment) - // jsep sets || at 1, and assignment at 0.9, and conditional should be between them - if ( - test.operator && - binaryOps[test.operator as BinaryOperator] <= 0.9 - ) { - let newTest = test as BinaryExpression - while ( - newTest.right.operator && - binaryOps[newTest.right.operator as BinaryOperator] <= 0.9 - ) { - newTest = newTest.right as BinaryExpression - } - env.node.test = newTest.right - newTest.right = env.node - env.node = test - } } else { throw this.__getError(msgExpectedColon) } @@ -1072,55 +1202,9 @@ class Jsep { } } - __fixBinaryArrow(env: HookResult): void { - this.__updateBinariesToArrows(env.node) - } - - __updateBinariesToArrows(node?: Expression | false): void { - if (!node) return - Object.values(node).forEach((val) => { - if (val && typeof val === 'object') { - this.__updateBinariesToArrows(val as Expression | false) - } - }) - - if (node.operator === '=>') { - node.type = ExpressionType.Arrow - node.params = node.left ? [node.left] : null - node.body = node.right - if ( - node.params && - (node.params as any)[0].type === ExpressionType.Sequence - ) { - node.params = (node.params as any)[0].expressions - } - delete node.left - delete node.right - delete node.operator - } - } - - __fixAssignmentOperators(env: HookResult): void { - if (env.node) this.__updateBinariesToAssignments(env.node) - } - - __updateBinariesToAssignments(node: Record): void { - if (assigmentOperatorsSet.has(node.operator)) { - node.type = ExpressionType.Assignment - this.__updateBinariesToAssignments(node.left) - this.__updateBinariesToAssignments(node.right) - } else if (!node.operator) { - Object.values(node).forEach((val) => { - if (val && typeof val === 'object') { - this.__updateBinariesToAssignments(val) - } - }) - } - } - __gobbleTaggedTemplateIdentifier(env: HookResult): void { - if (!env.node) return - const type = env.node.type + const node = env.node as Expression + const type = node.type const condition = (type === ExpressionType.Identifier || type === ExpressionType.Member) && this.__isCode(BTICK_CODE) @@ -1128,82 +1212,86 @@ class Jsep { env.node = { type: ExpressionType.TaggedTemplateExpression, - tag: env.node, + tag: node, quasi: this.__gobbleTemplateLiteral(env), } } __gobbleTemplateLiteral(env: HookResult): TemplateLiteral | undefined { if (!this.__isCode(BTICK_CODE)) return + const expr = this.__expr + const length = expr.length const node: TemplateLiteral = { type: ExpressionType.TemplateLiteral, quasis: [], expressions: [], } - let cooked = '' - let raw = '' - let closed = false - const length = this.__expr.length - const pushQuasi = (): number => - node.quasis.push({ + let segmentStart = ++this.__index + const rawParts: string[] = [] + const cookedParts: string[] = [] + let hasEscape = false + + const pushQuasi = (tail: boolean): number => { + if (!hasEscape) { + const value = expr.slice(segmentStart, this.__index) + return node.quasis.push({ + type: ExpressionType.TemplateElement, + value: { + raw: value, + cooked: value, + }, + tail, + }) + } + + rawParts.push(expr.slice(segmentStart, this.__index)) + cookedParts.push(expr.slice(segmentStart, this.__index)) + const raw = rawParts.join('') + const cooked = cookedParts.join('') + rawParts.length = 0 + cookedParts.length = 0 + hasEscape = false + return node.quasis.push({ type: ExpressionType.TemplateElement, value: { raw, cooked, }, - tail: closed, + tail, }) + } while (this.__index < length) { - let ch = this.__expr.charAt(++this.__index) + const code = expr.charCodeAt(this.__index) - if (ch === '`') { + if (code === BTICK_CODE) { + pushQuasi(true) this.__index += 1 - closed = true - pushQuasi() - env.node = node return node - } else if (ch === '$' && this.__expr.charAt(this.__index + 1) === '{') { + } else if ( + code === 36 && // $ + expr.charCodeAt(this.__index + 1) === OCURLY_CODE + ) { + pushQuasi(false) this.__index += 2 - pushQuasi() - raw = '' - cooked = '' node.expressions.push(...this.__gobbleExpressions(CCURLY_CODE)) if (!this.__isCode(CCURLY_CODE)) { throw this.__getError('unclosed ${') } - } else if (ch === '\\') { - // Check for all of the common escape codes - raw += ch - ch = this.__expr.charAt(++this.__index) - raw += ch - - switch (ch) { - case 'n': - cooked += '\n' - break - case 'r': - cooked += '\r' - break - case 't': - cooked += '\t' - break - case 'b': - cooked += '\b' - break - case 'f': - cooked += '\f' - break - case 'v': - cooked += '\x0B' - break - default: - cooked += ch - } + this.__index += 1 + segmentStart = this.__index + } else if (code === BSLASH_CODE) { + if (!hasEscape) hasEscape = true + rawParts.push(expr.slice(segmentStart, this.__index)) + cookedParts.push(expr.slice(segmentStart, this.__index)) + const escCode = expr.charCodeAt(this.__index + 1) + rawParts.push(expr.slice(this.__index, this.__index + 2)) + cookedParts.push(this.__decodeEscapedChar(escCode)) + this.__index += 2 + segmentStart = this.__index } else { - cooked += ch - raw += ch + this.__index += 1 } } throw this.__getError('Unclosed `') @@ -1224,7 +1312,7 @@ class Jsep { while ( callNode.type === ExpressionType.Member || (callNode.type === ExpressionType.Call && - callNode?.callee?.type === ExpressionType.Member) + callNode.callee.type === ExpressionType.Member) ) { callNode = callNode.type === ExpressionType.Member diff --git a/src/reactivity/isRef.ts b/src/reactivity/isRef.ts index 5e2d982..2a75f76 100644 --- a/src/reactivity/isRef.ts +++ b/src/reactivity/isRef.ts @@ -2,5 +2,5 @@ import { type AnyRef } from '../api/types' import { srefSymbol } from './refSymbols' export const isRef = (value: unknown): value is AnyRef => { - return (value as any)?.[srefSymbol] === 1 + return value != null && (value as any)[srefSymbol] === 1 } diff --git a/src/reactivity/unref.ts b/src/reactivity/unref.ts index 7f8324d..75beafb 100644 --- a/src/reactivity/unref.ts +++ b/src/reactivity/unref.ts @@ -1,6 +1,9 @@ import { type UnwrapRef } from '../api/types' -import { isRef } from './isRef' +import { srefSymbol } from './refSymbols' export const unref = (value: TValueType): UnwrapRef => { - return (isRef(value) ? value() : value) as UnwrapRef + const anyValue = value as any + return ( + anyValue != null && anyValue[srefSymbol] === 1 ? anyValue() : anyValue + ) as UnwrapRef } From f8e273ec0c1134fbab7ada1f82f70fbf1580987d Mon Sep 17 00:00:00 2001 From: Ahmed Yasin Koculu Date: Tue, 24 Feb 2026 08:36:16 +0100 Subject: [PATCH 2/4] coverage --- tests/app/binders-reactivity-e2e.spec.ts | 401 ++++++++++++++++ tests/app/class-context.spec.ts | 2 +- tests/app/component-head.spec.ts | 51 ++ tests/app/createApp.spec.ts | 73 ++- tests/app/createComponent.spec.ts | 351 +++++++++++++- tests/app/preprocess-template.spec.ts | 16 + tests/app/regor-config.spec.ts | 43 ++ tests/app/toFragment.spec.ts | 60 ++- tests/bind/binder-edge.spec.ts | 151 ++++++ tests/bind/binder.spec.ts | 66 +++ tests/bind/component-binder-edge.spec.ts | 140 ++++++ tests/bind/dynamic-binder-edge.spec.ts | 46 ++ tests/bind/dynamic-binder.spec.ts | 130 ++++++ tests/bind/for-binder-edge.spec.ts | 55 +++ tests/bind/for-binder.spec.ts | 153 ++++++ tests/bind/if-binder.spec.ts | 66 +++ tests/bind/mount-list.spec.ts | 49 ++ tests/bind/switch.spec.ts | 59 +++ tests/common/loose-equal.spec.ts | 17 + tests/composition/stack.spec.ts | 58 +++ tests/computed/watchEffect.spec.ts | 30 ++ tests/coverage/utility-edge.spec.ts | 206 ++++++++ tests/directives/attr.spec.ts | 92 ++++ tests/directives/class.spec.ts | 36 ++ tests/directives/html.spec.ts | 33 ++ tests/directives/model-edge.spec.ts | 390 ++++++++++++++++ tests/directives/model.spec.ts | 196 +++++++- tests/directives/on-advanced.spec.ts | 277 +++++++++++ tests/directives/prop.spec.ts | 131 ++++++ tests/directives/props-once.spec.ts | 73 +++ tests/directives/r-for.spec.ts | 33 ++ tests/directives/ref.spec.ts | 59 +++ tests/directives/single-prop.spec.ts | 204 ++++++++ tests/directives/style.spec.ts | 93 ++++ tests/directives/text.spec.ts | 37 ++ tests/directives/value.spec.ts | 22 + .../{registerDomGlobals.ts => createDom.ts} | 14 +- tests/minidom/minidom-regor.test.ts | 80 ++-- tests/minidom/minidom.test.ts | 440 ++++++++++-------- tests/minidom/minidom.ts | 259 +++++++++-- tests/parser/jsep-regor-e2e.spec.ts | 178 +++++++ tests/parser/jsep.spec.ts | 212 +++++++++ tests/parser/parser-edge.spec.ts | 47 ++ tests/parser/regor-eval.spec.ts | 416 +++++++++++++++++ tests/setup.ts | 15 +- 45 files changed, 5247 insertions(+), 313 deletions(-) create mode 100644 tests/app/binders-reactivity-e2e.spec.ts create mode 100644 tests/app/component-head.spec.ts create mode 100644 tests/app/regor-config.spec.ts create mode 100644 tests/bind/binder-edge.spec.ts create mode 100644 tests/bind/binder.spec.ts create mode 100644 tests/bind/component-binder-edge.spec.ts create mode 100644 tests/bind/dynamic-binder-edge.spec.ts create mode 100644 tests/bind/dynamic-binder.spec.ts create mode 100644 tests/bind/for-binder-edge.spec.ts create mode 100644 tests/bind/for-binder.spec.ts create mode 100644 tests/bind/if-binder.spec.ts create mode 100644 tests/bind/mount-list.spec.ts create mode 100644 tests/bind/switch.spec.ts create mode 100644 tests/composition/stack.spec.ts create mode 100644 tests/coverage/utility-edge.spec.ts create mode 100644 tests/directives/attr.spec.ts create mode 100644 tests/directives/html.spec.ts create mode 100644 tests/directives/model-edge.spec.ts create mode 100644 tests/directives/on-advanced.spec.ts create mode 100644 tests/directives/prop.spec.ts create mode 100644 tests/directives/props-once.spec.ts create mode 100644 tests/directives/ref.spec.ts create mode 100644 tests/directives/single-prop.spec.ts create mode 100644 tests/directives/style.spec.ts create mode 100644 tests/directives/text.spec.ts create mode 100644 tests/directives/value.spec.ts rename tests/minidom/{registerDomGlobals.ts => createDom.ts} (88%) create mode 100644 tests/parser/jsep-regor-e2e.spec.ts create mode 100644 tests/parser/jsep.spec.ts create mode 100644 tests/parser/parser-edge.spec.ts create mode 100644 tests/parser/regor-eval.spec.ts diff --git a/tests/app/binders-reactivity-e2e.spec.ts b/tests/app/binders-reactivity-e2e.spec.ts new file mode 100644 index 0000000..d2d9894 --- /dev/null +++ b/tests/app/binders-reactivity-e2e.spec.ts @@ -0,0 +1,401 @@ +import { expect, test } from 'vitest' + +import { createApp, createComponent, drainUnbind, html, ref } from '../../src' + +type KeyedLabelSource = { + payload: string + label: string +} + +type NameRow = { + id: number + name: string +} + +type ViewState = { + show: boolean + rows: NameRow[] +} + +type CardItem = { + title: string +} + +type LabelRow = { + id: number + label: string +} + +type MetaSource = { + payload: string +} + +type TextStyleShowSource = { + text: string + active: boolean + color: string + visible: boolean +} + +type FormState = { + title: string +} + +const withApp = async ( + context: Record, + template: string, + run: (root: HTMLElement) => void | Promise, +) => { + const root = document.createElement('div') + const app = createApp(context, { + element: root, + template: html`${template}`, + }) + try { + await run(root) + } finally { + app.unmount() + await drainUnbind() + } +} + +test('e2e: dynamic r-bind option reacts to source replacement and detaches old source', async () => { + const dynamicKey = ref('title') + const source = ref({ + payload: 'A', + label: 'alpha', + }) + + await withApp( + { dynamicKey, source }, + ``, + (root) => { + const btn = root.querySelector('#btn') as HTMLButtonElement | null + if (!btn) throw new Error('missing #btn') + + expect(btn.getAttribute('title')).toBe('A') + expect(btn.textContent).toBe('alpha') + + source().payload('B') + source().label('beta') + expect(btn.getAttribute('title')).toBe('B') + expect(btn.textContent).toBe('beta') + + dynamicKey('data-id') + expect(btn.getAttribute('title')).toBeNull() + expect(btn.getAttribute('data-id')).toBe('B') + + const oldSource = source() + source( + ref({ + payload: '42', + label: 'gamma', + }), + ) + + expect(btn.getAttribute('data-id')).toBe('42') + expect(btn.textContent).toBe('gamma') + + source().payload('43') + source().label('delta') + expect(btn.getAttribute('data-id')).toBe('43') + expect(btn.textContent).toBe('delta') + + // Old source must be detached after replacement. + oldSource.payload('stale') + oldSource.label('stale-label') + dynamicKey('aria-label') + expect(btn.getAttribute('data-id')).toBeNull() + expect(btn.getAttribute('aria-label')).toBe('43') + dynamicKey('data-id') + expect(btn.getAttribute('data-id')).toBe('43') + expect(btn.textContent).toBe('delta') + }, + ) +}) + +test('e2e: r-if + r-for stays reactive across source replacement', async () => { + const view = ref({ + show: true, + rows: [ + { id: 1, name: 'A' }, + { id: 2, name: 'B' }, + ], + }) + + await withApp( + { view }, + `
+
    +
  • +
+

empty

+
`, + (root) => { + const names = () => + [...root.querySelectorAll('li')].map((x) => x.textContent) + const isEmpty = () => !!root.querySelector('#empty') + + expect(names()).toStrictEqual(['A', 'B']) + expect(isEmpty()).toBe(false) + + view().show(false) + expect(names()).toStrictEqual([]) + expect(isEmpty()).toBe(true) + + const oldView = view() + view( + ref({ + show: true, + rows: [ + { id: 10, name: 'X' }, + { id: 11, name: 'Y' }, + ], + }), + ) + + expect(names()).toStrictEqual(['X', 'Y']) + expect(isEmpty()).toBe(false) + + view().rows()[0]().name('Z') + expect(names()).toStrictEqual(['Z', 'Y']) + + // Old source must no longer drive UI. + oldView.show(false) + oldView.rows()[0]().name('OLD') + expect(names()).toStrictEqual(['Z', 'Y']) + expect(isEmpty()).toBe(false) + }, + ) +}) + +test('e2e: :is component switching keeps prop reactivity and detaches old sources', async () => { + const currentCard = ref('cardA') + const item = ref({ title: 'first' }) + + const cardA = createComponent( + html`

`, + { + props: ['item'], + }, + ) + const cardB = createComponent( + html`

`, + { + props: ['item'], + }, + ) + + await withApp( + { + currentCard, + item, + components: { cardA, cardB }, + }, + `
`, + (root) => { + const title = () => root.querySelector('h3')?.textContent + const cardClass = () => root.querySelector('article')?.className + + expect(cardClass()).toBe('a') + expect(title()).toBe('first') + + item().title('second') + expect(title()).toBe('second') + + currentCard('cardB') + expect(cardClass()).toBe('b') + expect(title()).toBe('second') + + const oldItem = item() + item(ref({ title: 'third' })) + expect(title()).toBe('third') + + oldItem.title('stale') + expect(title()).toBe('third') + }, + ) +}) + +test('e2e: mixed binders detach after unmount (post-unmount source churn is safe)', async () => { + const root = document.createElement('div') + const show = ref(true) + const rows = ref([ + { id: 1, label: 'A' }, + { id: 2, label: 'B' }, + ]) + const dynamicKey = ref('title') + const meta = ref({ payload: 'x' }) + const currentCard = ref('cardA') + + const cardA = createComponent( + html`
`, + { props: ['meta'] }, + ) + const cardB = createComponent( + html`
`, + { props: ['meta'] }, + ) + + const app = createApp( + { + show, + rows, + dynamicKey, + meta, + currentCard, + components: { cardA, cardB }, + }, + { + element: root, + template: html`
+
    +
  • +
+

empty

+ +
+
`, + }, + ) + + expect(root.querySelectorAll('li').length).toBe(2) + expect(root.querySelector('#meta')?.getAttribute('title')).toBe('x') + expect(root.querySelector('article')?.className).toBe('a') + + app.unmount() + await drainUnbind() + const htmlAfterUnmount = root.innerHTML + const listCountAfterUnmount = root.querySelectorAll('li').length + const metaAfterUnmount = root.querySelector('#meta')?.getAttribute('title') + const articleClassAfterUnmount = root.querySelector('article')?.className + + // Post-unmount source churn must not remount/update anything. + show(false) + rows().push(ref({ id: 3, label: 'C' })) + dynamicKey('data-id') + meta().payload('y') + currentCard('cardB') + + expect(root.innerHTML).toBe(htmlAfterUnmount) + expect(root.querySelectorAll('li').length).toBe(listCountAfterUnmount) + expect(root.querySelector('#meta')?.getAttribute('title')).toBe( + metaAfterUnmount, + ) + expect(root.querySelector('article')?.className).toBe( + articleClassAfterUnmount, + ) +}) + +test('e2e: r-text/r-class/r-style/r-show stay reactive after source replacement', async () => { + const source = ref({ + text: 'alpha', + active: true, + color: 'red', + visible: true, + }) + + await withApp( + { source }, + `
+

+

x

+

x

+

visible

+
`, + (root) => { + const text = root.querySelector('#text') as HTMLElement | null + const classEl = root.querySelector('#class') as HTMLElement | null + const styleEl = root.querySelector('#style') as HTMLElement | null + const showEl = root.querySelector('#show') as HTMLElement | null + if (!text || !classEl || !styleEl || !showEl) + throw new Error('missing nodes') + + expect(text.textContent).toBe('alpha') + expect(classEl.classList.contains('active')).toBe(true) + expect(styleEl.style.color).toBe('red') + expect(showEl.style.display).toBe('') + + source().text('beta') + source().active(false) + source().color('blue') + source().visible(false) + expect(text.textContent).toBe('beta') + expect(classEl.classList.contains('active')).toBe(false) + expect(styleEl.style.color).toBe('blue') + expect(showEl.style.display).toBe('none') + + const oldSource = source() + source( + ref({ + text: 'gamma', + active: true, + color: 'green', + visible: true, + }), + ) + expect(text.textContent).toBe('gamma') + expect(classEl.classList.contains('active')).toBe(true) + expect(styleEl.style.color).toBe('green') + expect(showEl.style.display).toBe('') + + // Ensure detached old source does not affect current DOM bindings. + oldSource.text('stale') + oldSource.active(false) + oldSource.color('black') + oldSource.visible(false) + expect(text.textContent).toBe('gamma') + expect(classEl.classList.contains('active')).toBe(true) + expect(styleEl.style.color).toBe('green') + expect(showEl.style.display).toBe('') + }, + ) +}) + +test('e2e: r-model input stays linked to latest source object after replacement', async () => { + const form = ref({ title: 'A' }) + + await withApp( + { form }, + `
+ +

+
`, + (root) => { + const input = root.querySelector( + '#title-input', + ) as HTMLInputElement | null + const text = root.querySelector('#title-text') as HTMLElement | null + if (!input || !text) throw new Error('missing input/text') + + expect(input.value).toBe('A') + expect(text.textContent).toBe('A') + + input.value = 'B' + input.dispatchEvent(new Event('input')) + expect(form().title()).toBe('B') + expect(text.textContent).toBe('B') + + const oldForm = form() + form(ref({ title: 'C' })) + expect(input.value).toBe('C') + expect(text.textContent).toBe('C') + + input.value = 'D' + input.dispatchEvent(new Event('input')) + expect(form().title()).toBe('D') + expect(text.textContent).toBe('D') + + // Old source should be detached and remain unchanged by new writes. + expect(oldForm.title()).toBe('B') + oldForm.title('STALE') + expect(input.value).toBe('D') + expect(text.textContent).toBe('D') + expect(form().title()).toBe('D') + }, + ) +}) diff --git a/tests/app/class-context.spec.ts b/tests/app/class-context.spec.ts index cccee77..ad191c4 100644 --- a/tests/app/class-context.spec.ts +++ b/tests/app/class-context.spec.ts @@ -613,7 +613,7 @@ test('class r-model binding survives multiple prop target replacements in nested expect(app.context.selectedHost().hostname()).toBe('gamma-child') }) -test('known issue: named slot content should render with class component context', () => { +test('named slot content should render with class component context', () => { class ShellContext { constructor(head: ComponentHead) { head.enableSwitch = true diff --git a/tests/app/component-head.spec.ts b/tests/app/component-head.spec.ts new file mode 100644 index 0000000..4d1b746 --- /dev/null +++ b/tests/app/component-head.spec.ts @@ -0,0 +1,51 @@ +import { expect, test, vi } from 'vitest' + +import { ComponentHead } from '../../src/app/ComponentHead' +import { + peekScope, + popScope, + pushScope, + setScope, +} from '../../src/composition/stack' + +test('component head emits custom events from source element', () => { + const host = document.createElement('div') + const start = document.createComment('s') + const end = document.createComment('e') + host.appendChild(start) + host.appendChild(end) + const head = new ComponentHead({}, host, [], start, end) + const spy = vi.fn() + host.addEventListener('save', spy) + + head.emit('save', { x: 1 }) + + expect(spy).toHaveBeenCalledTimes(1) + expect((spy.mock.calls[0][0] as CustomEvent).detail).toEqual({ x: 1 }) +}) + +test('component head unmount removes nodes between markers and calls unmounted hooks', async () => { + const host = document.createElement('div') + const start = document.createComment('s') + const n1 = document.createElement('span') + const n2 = document.createElement('span') + const end = document.createComment('e') + host.appendChild(start) + host.appendChild(n1) + host.appendChild(n2) + host.appendChild(end) + + const stop = vi.fn() + const ctx = {} as any + pushScope() + setScope(ctx) + ;(peekScope() as any).onUnmounted.push(stop) + popScope() + const head = new ComponentHead({}, host, [ctx], start, end) + + head.unmount() + await new Promise((resolve) => setTimeout(resolve, 5)) + + expect(host.contains(n1)).toBe(false) + expect(stop).toHaveBeenCalledTimes(1) +}) diff --git a/tests/app/createApp.spec.ts b/tests/app/createApp.spec.ts index 8a67b14..c37d532 100644 --- a/tests/app/createApp.spec.ts +++ b/tests/app/createApp.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from 'vitest' -import { createApp, html, raw, ref } from '../../src' +import { createApp, html, raw, ref, RegorConfig, useScope } from '../../src' test('hello world', () => { const root = document.createElement('div') @@ -70,3 +70,74 @@ test('interpolation supports both syntaxes at once', () => { expect(root.textContent).toBe('hello world') }) + +test('createApp mounts json template and supports unbind', () => { + const root = document.createElement('div') + root.appendChild(document.createElement('span')) + const app = createApp( + { msg: ref('json') }, + { + element: root, + json: { + t: 'div', + c: [{ d: 'ok' }], + } as any, + }, + ) + + expect(root.textContent).toBe('ok') + app.unbind() +}) + +test('createApp supports string templates and scope contexts', () => { + class ScopeCtx { + msg = ref('scope-ok') + } + const appRoot = document.createElement('div') + appRoot.id = 'app' + document.body.appendChild(appRoot) + createApp( + useScope(() => new ScopeCtx()), + '

{{ msg }}

', + ) + expect(appRoot.querySelector('p')?.textContent).toBe('scope-ok') + appRoot.remove() + + const root = document.createElement('div') + createApp( + { msg: ref('inline') }, + { element: root, template: '

{{ msg }}

' }, + ) + expect(root.querySelector('p')?.textContent).toBe('inline') +}) + +test('createApp throws when selector root is missing and can disable interpolation', () => { + expect(() => + createApp({}, { selector: '#__missing__regor_root__' }), + ).toThrow() + + const root = document.createElement('div') + const cfg = new RegorConfig() + cfg.useInterpolation = false + createApp( + { msg: ref('x') }, + { element: root, template: '

{{ msg }}

' }, + cfg, + ) + expect(root.querySelector('p')?.textContent).toBe('{{ msg }}') +}) + +test('createApp supports default #app selector and throws when no root source provided', () => { + const appRoot = document.createElement('div') + appRoot.id = 'app' + document.body.appendChild(appRoot) + try { + const app = createApp({ msg: ref('auto-root') }) + expect(app.context.msg()).toBe('auto-root') + app.unbind() + } finally { + appRoot.remove() + } + + expect(() => createApp({}, { template: '
x
' } as any)).toThrow() +}) diff --git a/tests/app/createComponent.spec.ts b/tests/app/createComponent.spec.ts index a2256ef..14aa5b2 100644 --- a/tests/app/createComponent.spec.ts +++ b/tests/app/createComponent.spec.ts @@ -1,4 +1,4 @@ -import { test } from 'vitest' +import { expect, test } from 'vitest' import { createApp, @@ -7,7 +7,11 @@ import { IRegorContext, Ref, ref, + RegorConfig, } from '../../src' +import { Binder } from '../../src/bind/Binder' +import { hasSwitch } from '../../src/bind/switch' +import { Parser } from '../../src/parser/Parser' import { htmlEqual } from '../common/html-equal' test('should render components with reactive properties', () => { @@ -332,3 +336,348 @@ test('should render nested component with reactive properties', () => { `, ) }) + +test('createComponent supports template element and selector sources', () => { + const source = document.createElement('template') + source.innerHTML = '
x
' + const fromElement = createComponent({ element: source }) + expect( + (fromElement.template as ParentNode).querySelector('.from-element'), + ).toBeTruthy() + + const host = document.createElement('div') + host.innerHTML = + '' + document.body.appendChild(host) + try { + const fromSelector = createComponent({ selector: '#cc-select' }) + expect( + (fromSelector.template as ParentNode).querySelector('.from-selector'), + ).toBeTruthy() + } finally { + host.remove() + } +}) + +test('createComponent throws for missing selector template', () => { + expect(() => + createComponent({ selector: '#__regor_component_missing__' }), + ).toThrow() +}) + +test('createComponent supports json templates and svg upgrade path', () => { + const fromJson = createComponent({ + json: { t: 'div', c: [{ d: 'json-ok' }] }, + }) + expect( + (fromJson.template as ParentNode).querySelector('div')?.textContent, + ).toContain('json-ok') + + const fromSvg = createComponent({ + template: + '
', + isSVG: true, + }) + const svg = (fromSvg.template as ParentNode).querySelector('svg') + expect(svg).toBeTruthy() +}) + +test('component inheritAttrs merges class and style from host onto inheritor root', () => { + const root = document.createElement('div') + const boxComp = createComponent(html`
box
`) + + createApp( + { components: { boxComp } }, + { + element: root, + template: html``, + }, + ) + + const div = root.querySelector('div') as HTMLDivElement + expect(div.classList.contains('base')).toBe(true) + expect(div.classList.contains('host-a')).toBe(true) + expect(div.classList.contains('host-b')).toBe(true) + expect(div.style.color).toBe('red') + expect(div.style.marginTop).toBe('5px') +}) + +test('component named slot fallback renders when slot content is not provided', () => { + const root = document.createElement('div') + const slotComp = createComponent( + html`
+ fallback-extra +
`, + ) + + createApp( + { components: { slotComp } }, + { + element: root, + template: html``, + }, + ) + + expect(root.querySelector('.fallback-extra')?.textContent).toBe( + 'fallback-extra', + ) +}) + +test('component with slot-only root expands default slot content', () => { + const root = document.createElement('div') + const onlySlot = createComponent(html``) + + createApp( + { components: { onlySlot } }, + { + element: root, + template: html`inside`, + }, + ) + + expect(root.querySelector('.inside')?.textContent).toBe('inside') +}) + +test('component named slot falls back to slot body when name is missing in host templates', () => { + const root = document.createElement('div') + const comp = createComponent( + html`
+ fallback +
`, + ) + createApp( + { components: { comp } }, + { + element: root, + template: html`default`, + }, + ) + expect(root.querySelector('.slot-fallback')?.textContent).toBe('fallback') +}) + +test('default slot ignores named-only template shortcuts and keeps element children', () => { + const root = document.createElement('div') + const shell = createComponent(html`
`) + createApp( + { components: { shell } }, + { + element: root, + template: html` + + plain + `, + }, + ) + expect(root.querySelector('.named')).toBeNull() + expect(root.querySelector('.plain')?.textContent).toBe('plain') +}) + +test('slot switch contexts are released on unmount', async () => { + const root = document.createElement('div') + const shell = createComponent(html`
`, { + context: (head) => { + head.enableSwitch = true + return {} + }, + }) + + const app = createApp( + { components: { shell } }, + { + element: root, + template: html`

slot

`, + }, + ) + + expect(hasSwitch()).toBe(true) + app.unmount() + await new Promise((resolve) => setTimeout(resolve, 5)) + expect(hasSwitch()).toBe(false) +}) + +test('createComponent supports disabling interpolation and direct element children scan', () => { + const rootEl = document.createElement('div') + rootEl.innerHTML = '

{{ msg }}

' + const comp = createComponent({ element: rootEl }, { useInterpolation: false }) + const template = comp.template as ParentNode + expect(template.querySelector('p')?.textContent).toBe('{{ msg }}') +}) + +test('component slot shorthand #name on slot element resolves named templates', () => { + const root = document.createElement('div') + const shell = createComponent( + html`
+ fallback +
`, + ) + + createApp( + { components: { shell } }, + { + element: root, + template: html` + + `, + }, + ) + + expect(root.querySelector('.named')?.textContent).toBe('named-slot') + expect(root.querySelector('.fallback')).toBeNull() +}) + +test('registered-only component lookup works without context components', () => { + const root = document.createElement('div') + const cfg = new RegorConfig() + const regOnly = createComponent('
ok
', { + defaultName: 'RegOnly', + }) + cfg.addComponent(regOnly) + + createApp( + {}, + { + element: root, + template: '', + }, + cfg, + ) + + expect(root.querySelector('.reg-only')?.textContent).toBe('ok') +}) + +test('dashed context component key is safely skipped when lookup key camelizes differently', () => { + const root = document.createElement('div') + const dashComp = createComponent('
x
') + + createApp( + { + components: { + 'my-comp': dashComp, + } as any, + }, + { + element: root, + template: '', + }, + ) + + // unresolved component remains untouched + expect(root.querySelector('my-comp')).toBeTruthy() +}) + +test('component binder handles components mounted under parent nodes without parentElement', () => { + const cfg = new RegorConfig() + const regOnly = createComponent('
ok
', { + defaultName: 'FragOnly', + }) + cfg.addComponent(regOnly) + const parser = new Parser([{}], cfg) + const binder = new Binder(parser) + + const frag = document.createDocumentFragment() + const el = document.createElement('frag-only') + frag.appendChild(el) + + expect(() => + (binder as any).__componentBinder.__bindAll(frag as any), + ).not.toThrow() + expect(frag.querySelector('frag-only')).toBeTruthy() +}) + +test('component props binding accepts .prop and r-bind:prop paths', () => { + const root = document.createElement('div') + const comp = createComponent(html`
{{ msg }}
`, { + props: ['msg'], + context: (head) => ({ + msg: head.props.msg, + }), + }) + + createApp( + { + components: { comp }, + dotMsg: ref('dot'), + bindMsg: ref('bind'), + }, + { + element: root, + template: html`
+ + +
`, + }, + ) + + const texts = [...root.querySelectorAll('.msg')].map((x) => x.textContent) + expect(texts).toContain('dot') + expect(texts).toContain('bind') +}) + +test('component context fallback to empty object works when context() returns undefined', () => { + const root = document.createElement('div') + const undefCtx = createComponent('
ok
', { + context: () => undefined as any, + }) + + createApp( + { + components: { undefCtx }, + }, + { + element: root, + template: '', + }, + ) + + expect(root.querySelector('.ctx-undef')?.textContent).toBe('ok') +}) + +test('component autoProps can be disabled', () => { + const root = document.createElement('div') + const comp = createComponent(html`
{{ msg }}
`, { + props: ['msg'], + context: (head) => { + head.autoProps = false + return { + msg: ref('local'), + } + }, + }) + + createApp( + { + components: { comp }, + external: ref('external'), + }, + { + element: root, + template: '', + }, + ) + + expect(root.querySelector('.no-auto')?.textContent).toBe('local') +}) + +test('component inheritAttrs false keeps host attrs off child root', () => { + const root = document.createElement('div') + const comp = createComponent('
x
', { + inheritAttrs: false, + }) + + createApp( + { components: { comp } }, + { + element: root, + template: + '', + }, + ) + + const child = root.querySelector('.base') as HTMLElement + expect(child.classList.contains('host-class')).toBe(false) +}) diff --git a/tests/app/preprocess-template.spec.ts b/tests/app/preprocess-template.spec.ts index 6c4f8c3..73044f5 100644 --- a/tests/app/preprocess-template.spec.ts +++ b/tests/app/preprocess-template.spec.ts @@ -70,3 +70,19 @@ test('should normalize self-closing kebab-case components in aliased tr', () => '', ) }) + +test('keeps malformed tags and unmatched closing tags unchanged', () => { + expect(preprocess('
< >
')).toBe('
< >
') + expect(preprocess('
x
')).toBe('
x
') +}) + +test('keeps special tags and unfinished tags unchanged', () => { + const special = '
x
' + expect(preprocess(special)).toBe(special) + expect(preprocess(' { + expect(preprocess('plain text only')).toBe('plain text only') + expect(preprocess('a