diff --git a/@types/animations/animate.d.ts b/@types/animations/animate.d.ts index d67a2d3b2..c49f6f1e3 100644 --- a/@types/animations/animate.d.ts +++ b/@types/animations/animate.d.ts @@ -212,74 +212,12 @@ export class AnimateProvider { */ enabled: any; /** - * Cancels the provided animation and applies the end state of the animation. - * Note that this does not cancel the underlying operation, e.g. the setting of classes or - * adding the element to the DOM. - * - * @param {import('./runner/animate-runner.js').AnimateRunner} runner An animation runner returned by an $animate function. - * - * @example - - - angular.module('animationExample', []).component('cancelExample', { - templateUrl: 'template.html', - controller: function($element, $animate) { - this.runner = null; - - this.addClass = function() { - this.runner = $animate.addClass($element.querySelectorAll('div'), 'red'); - let ctrl = this; - this.runner.finally(function() { - ctrl.runner = null; - }); - }; - - this.removeClass = function() { - this.runner = $animate.removeClass($element.querySelectorAll('div'), 'red'); - let ctrl = this; - this.runner.finally(function() { - ctrl.runner = null; - }); - }; - - this.cancel = function() { - $animate.cancel(this.runner); - }; - } - }); - - -

- - -
- -
-

CSS-Animated Text
-

-
- - - - - .red-add, .red-remove { - transition: all 4s cubic-bezier(0.250, 0.460, 0.450, 0.940); - } - - .red, - .red-add.red-add-active { - color: #FF0000; - font-size: 40px; - } - - .red-remove.red-remove-active { - font-size: 10px; - color: black; - } - - -
- */ + * Cancels the provided animation and applies the end state of the animation. + * Note that this does not cancel the underlying operation, e.g. the setting of classes or + * adding the element to the DOM. + * + * @param {import('./runner/animate-runner.js').AnimateRunner} runner An animation runner returned by an $animate function. + */ cancel( runner: import("./runner/animate-runner.js").AnimateRunner, ): void; diff --git a/@types/core/compile/attributes.d.ts b/@types/core/compile/attributes.d.ts index 899b7cb00..390584f87 100644 --- a/@types/core/compile/attributes.d.ts +++ b/@types/core/compile/attributes.d.ts @@ -1,7 +1,7 @@ export class Attributes { static $nonscope: boolean; /** - * @param {ng.Scope} $rootScope + * @param {ng.RootScopeService} $rootScope * @param {*} $animate * @param {ng.ExceptionHandlerService} $exceptionHandler * @param {*} $sce @@ -9,7 +9,7 @@ export class Attributes { * @param {Object} [attributesToCopy] */ constructor( - $rootScope: ng.Scope, + $rootScope: ng.RootScopeService, $animate: any, $exceptionHandler: ng.ExceptionHandlerService, $sce: any, diff --git a/@types/core/di/ng-module.d.ts b/@types/core/di/ng-module.d.ts index 087cb7a25..09647b445 100644 --- a/@types/core/di/ng-module.d.ts +++ b/@types/core/di/ng-module.d.ts @@ -49,6 +49,7 @@ export class NgModule { /** @type {!Array.>} */ runBlocks: Array>; services: any[]; + wasmModules: any[]; /** * @param {string} name * @param {any} object @@ -153,4 +154,10 @@ export class NgModule { name: string, ctlFn: import("../../interface.ts").Injectable, ): NgModule; + /** + * @param {string} name + * @param {string} src + * @returns {NgModule} + */ + wasm(name: string, src: string): NgModule; } diff --git a/@types/core/sanitize/interface.d.ts b/@types/core/sanitize/interface.d.ts new file mode 100644 index 000000000..0046947df --- /dev/null +++ b/@types/core/sanitize/interface.d.ts @@ -0,0 +1,8 @@ +/** + * Sanitizer function that processes a URI string and optionally + * treats it as a media URL. + */ +export type SanitizerFn = ( + uri: string | null | undefined, + isMediaUrl?: boolean, +) => string | null | undefined; diff --git a/@types/core/sanitize/sanitize-uri.d.ts b/@types/core/sanitize/sanitize-uri.d.ts index 17d3229e3..1ca5acb40 100644 --- a/@types/core/sanitize/sanitize-uri.d.ts +++ b/@types/core/sanitize/sanitize-uri.d.ts @@ -33,8 +33,11 @@ export class SanitizeUriProvider implements ServiceProvider { regexp?: RegExp | undefined, ): RegExp | SanitizeUriProvider; /** - * @returns {import("./interface").SanitizerFn} + * @returns {import("./interface.ts").SanitizerFn} */ - $get(): any; + $get: ( + | string + | (($window: ng.WindowService) => import("./interface.ts").SanitizerFn) + )[]; } export type ServiceProvider = import("../../interface.ts").ServiceProvider; diff --git a/@types/core/scope/interface.d.ts b/@types/core/scope/interface.d.ts index b46815bf4..56f7e6695 100644 --- a/@types/core/scope/interface.d.ts +++ b/@types/core/scope/interface.d.ts @@ -5,10 +5,10 @@ export interface AsyncQueueTask { fn: (...args: any[]) => any; locals: Record; } -export type ListenerFunction = (newValue: any, originalTarget: object) => void; +export type ListenerFn = (newValue?: any, originalTarget?: object) => void; export interface Listener { originalTarget: object; - listenerFn: ListenerFunction; + listenerFn: ListenerFn; watchFn: CompiledExpression; id: number; scopeId: number; diff --git a/@types/core/scope/scope.d.ts b/@types/core/scope/scope.d.ts index 47d3feb5a..1f80bd38d 100644 --- a/@types/core/scope/scope.d.ts +++ b/@types/core/scope/scope.d.ts @@ -128,12 +128,12 @@ export class Scope { * function is invoked when changes to that property are detected. * * @param {string} watchProp - An expression to be watched in the context of this model. - * @param {import('./interface.ts').ListenerFunction} [listenerFn] - A function to execute when changes are detected on watched context. + * @param {ng.ListenerFn} [listenerFn] - A function to execute when changes are detected on watched context. * @param {boolean} [lazy] - A flag to indicate if the listener should be invoked immediately. Defaults to false. */ $watch( watchProp: string, - listenerFn?: import("./interface.ts").ListenerFunction, + listenerFn?: ng.ListenerFn, lazy?: boolean, ): () => void; $new(childInstance: any): any; diff --git a/@types/directive/wasm/wasm.d.ts b/@types/directive/wasm/wasm.d.ts new file mode 100644 index 000000000..5b323a182 --- /dev/null +++ b/@types/directive/wasm/wasm.d.ts @@ -0,0 +1,3 @@ +export function ngWasmDirective(): { + link: ($scope: any, _: any, $attrs: any) => Promise; +}; diff --git a/@types/namespace.d.ts b/@types/namespace.d.ts index a4d0e21e4..1c26c3434 100644 --- a/@types/namespace.d.ts +++ b/@types/namespace.d.ts @@ -2,6 +2,10 @@ export { angular } from "./index.js"; import { Angular as TAngular } from "./angular.js"; import { Attributes as TAttributes } from "./core/compile/attributes.js"; import { Scope as TScope } from "./core/scope/scope.js"; +import { + ListenerFn as TListenerFn, + Listener as TListener, +} from "./core/scope/interface.ts"; import { NgModule as TNgModule } from "./core/di/ng-module.js"; import { InjectorService as TInjectorService } from "./core/di/internal-injector.js"; import { @@ -107,8 +111,10 @@ declare global { type TemplateCacheService = Map; type TemplateRequestService = TTemplateRequestService; type ErrorHandlingConfig = TErrorHandlingConfig; - type WindowService = Window; + type ListenerFn = TListenerFn; + type Listener = TListener; type DocumentService = Document; + type WindowService = Window; type WorkerConfig = TWorkerConfig; type WorkerConnection = TWorkerConnection; } diff --git a/@types/services/sce/sce.d.ts b/@types/services/sce/sce.d.ts index 4c3a225ce..9fb472893 100644 --- a/@types/services/sce/sce.d.ts +++ b/@types/services/sce/sce.d.ts @@ -24,85 +24,7 @@ export namespace SCE_CONTEXTS { * `$sceDelegate` is a service that is used by the `$sce` service to provide {@link ng.$sce Strict * Contextual Escaping (SCE)} services to AngularTS. * - * For an overview of this service and the functionnality it provides in AngularTS, see the main - * page for {@link ng.$sce SCE}. The current page is targeted for developers who need to alter how - * SCE works in their application, which shouldn't be needed in most cases. - * - *
- * AngularTS strongly relies on contextual escaping for the security of bindings: disabling or - * modifying this might cause cross site scripting (XSS) vulnerabilities. For libraries owners, - * changes to this service will also influence users, so be extra careful and document your changes. - *
- * - * Typically, you would configure or override the {@link ng.$sceDelegate $sceDelegate} instead of - * the `$sce` service to customize the way Strict Contextual Escaping works in AngularTS. This is - * because, while the `$sce` provides numerous shorthand methods, etc., you really only need to - * override 3 core functions (`trustAs`, `getTrusted` and `valueOf`) to replace the way things - * work because `$sce` delegates to `$sceDelegate` for these operations. - * - * Refer {@link ng.$sceDelegateProvider $sceDelegateProvider} to configure this service. - * - * The default instance of `$sceDelegate` should work out of the box with little pain. While you - * can override it completely to change the behavior of `$sce`, the common case would - * involve configuring the {@link ng.$sceDelegateProvider $sceDelegateProvider} instead by setting - * your own trusted and banned resource lists for trusting URLs used for loading AngularTS resources - * such as templates. Refer {@link ng.$sceDelegateProvider#trustedResourceUrlList - * $sceDelegateProvider.trustedResourceUrlList} and {@link - * ng.$sceDelegateProvider#bannedResourceUrlList $sceDelegateProvider.bannedResourceUrlList} - */ -/** - * - * The `$sceDelegateProvider` provider allows developers to configure the {@link ng.$sceDelegate - * $sceDelegate service}, used as a delegate for {@link ng.$sce Strict Contextual Escaping (SCE)}. - * - * The `$sceDelegateProvider` allows one to get/set the `trustedResourceUrlList` and - * `bannedResourceUrlList` used to ensure that the URLs used for sourcing AngularTS templates and - * other script-running URLs are safe (all places that use the `$sce.RESOURCE_URL` context). See - * {@link ng.$sceDelegateProvider#trustedResourceUrlList - * $sceDelegateProvider.trustedResourceUrlList} and - * {@link ng.$sceDelegateProvider#bannedResourceUrlList $sceDelegateProvider.bannedResourceUrlList}, - * - * For the general details about this service in AngularTS, read the main page for {@link ng.$sce - * Strict Contextual Escaping (SCE)}. - * - * **Example**: Consider the following case. - * - * - your app is hosted at url `http://myapp.example.com/` - * - but some of your templates are hosted on other domains you control such as - * `http://srv01.assets.example.com/`, `http://srv02.assets.example.com/`, etc. - * - and you have an open redirect at `http://myapp.example.com/clickThru?...`. - * - * Here is what a secure configuration for this scenario might look like: - * - * ``` - * angular.module('myApp', []).config(function($sceDelegateProvider) { - * $sceDelegateProvider.trustedResourceUrlList([ - * // Allow same origin resource loads. - * 'self', - * // Allow loading from our assets domain. Notice the difference between * and **. - * 'http://srv*.assets.example.com/**' - * ]); - * - * // The banned resource URL list overrides the trusted resource URL list so the open redirect - * // here is blocked. - * $sceDelegateProvider.bannedResourceUrlList([ - * 'http://myapp.example.com/clickThru**' - * ]); - * }); - * ``` - * Note that an empty trusted resource URL list will block every resource URL from being loaded, and will require - * you to manually mark each one as trusted with `$sce.trustAsResourceUrl`. However, templates - * requested by {@link ng.$templateRequest $templateRequest} that are present in - * {@link ng.$templateCache $templateCache} will not go through this check. If you have a mechanism - * to populate your templates in that cache at config time, then it is a good idea to remove 'self' - * from the trusted resource URL lsit. This helps to mitigate the security impact of certain types - * of issues, like for instance attacker-controlled `ng-includes`. - */ -/** - * `$sceDelegate` is a service that is used by the `$sce` service to provide {@link ng.$sce Strict - * Contextual Escaping (SCE)} services to AngularTS. - * - * For an overview of this service and the functionnality it provides in AngularTS, see the main + * For an overview of this service and the functionality it provides in AngularTS, see the main * page for {@link ng.$sce SCE}. The current page is targeted for developers who need to alter how * SCE works in their application, which shouldn't be needed in most cases. * @@ -179,7 +101,7 @@ export namespace SCE_CONTEXTS { export class SceDelegateProvider { /** * - * @param {Array=} trustedResourceUrlList When provided, replaces the trustedResourceUrlList with + * @param {Array=} value When provided, replaces the trustedResourceUrlList with * the value provided. This must be an array or null. A snapshot of this array is used so * further changes to the array are ignored. * Follow {@link ng.$sce#resourceUrlPatternItem this link} for a description of the items @@ -198,7 +120,7 @@ export class SceDelegateProvider { * its origin with other apps! It is a good idea to limit it to only your application's directory. * */ - trustedResourceUrlList: (value: any, ...args: any[]) => any[]; + trustedResourceUrlList: (value?: any[] | undefined, ...args: any[]) => any[]; /** * * @param {Array=} bannedResourceUrlList When provided, replaces the `bannedResourceUrlList` with @@ -226,7 +148,7 @@ export class SceDelegateProvider { | string | (( $injector: ng.InjectorService, - $$sanitizeUri: any, + $$sanitizeUri: import("../../core/sanitize/interface.ts").SanitizerFn, $exceptionHandler: ng.ExceptionHandlerService, ) => { trustAs: (type: string, trustedValue: any) => any; diff --git a/@types/shared/utils.d.ts b/@types/shared/utils.d.ts index 3764dcee5..62fbea116 100644 --- a/@types/shared/utils.d.ts +++ b/@types/shared/utils.d.ts @@ -488,7 +488,18 @@ export function toDebugString(obj: any): any; * The resulting string key is in 'type:hashKey' format. */ export function hashKey(obj: any): string; -export function mergeClasses(a: any, b: any): any; +/** + * Merges two class name values into a single space-separated string. + * Accepts strings, arrays of strings, or null/undefined values. + * + * @param {string | string[] | null | undefined} a - The first class name(s). + * @param {string | string[] | null | undefined} b - The second class name(s). + * @returns {string} A single string containing all class names separated by spaces. + */ +export function mergeClasses( + a: string | string[] | null | undefined, + b: string | string[] | null | undefined, +): string; /** * Converts all accepted directives format into proper directive name. * @param {string} name Name to normalize @@ -571,5 +582,12 @@ export function wait(t?: number): Promise; * // returns false */ export function startsWith(str: string, search: string): boolean; +/** + * Loads and instantiates a WebAssembly module with proper error handling. + * + * @param {string} src - URL to the wasm file + * @returns {Promise} - Resolves to wasm exports + */ +export function instantiateWasm(src: string): Promise; export const isProxySymbol: unique symbol; export const ngAttrPrefixes: string[]; diff --git a/index.html b/index.html index 4bc4fa906..2163fecf3 100644 --- a/index.html +++ b/index.html @@ -54,16 +54,19 @@ } class Demo { - constructor($eventBus, $scope) { - $eventBus.subscribe("demo", (val) => { - $scope["$ctrl"].mailBox = val; - }); + constructor($eventBus, $scope, adder) { + this.adder = adder; + } + + async test() { + this.y = (await this.adder).add(1, 2); } } window.angular .module("todo", []) .controller("TodoCtrl", TodoController) - .controller("DemoCtrl", Demo); + .controller("DemoCtrl", Demo) + .wasm("adder", "src/directive/wasm/math.wasm"); }); @@ -98,28 +101,13 @@ -
-

Todos

- -
    -
  • - {{ todo.task }} {{ todo.done }} - -
  • -
-
- - -
- - -
- -
Fade me in out
- - +
+ {{ $ctrl.y }} +
+ + + + {{ x }} diff --git a/package-lock.json b/package-lock.json index 20fcdd78e..783e27e9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,31 +10,31 @@ "license": "MIT", "devDependencies": { "@eslint/js": "^9.28.0", - "@playwright/test": "latest", - "@rollup/plugin-commonjs": "latest", - "@rollup/plugin-node-resolve": "latest", - "@rollup/plugin-terser": "latest", - "@types/jasmine": "latest", + "@playwright/test": "*", + "@rollup/plugin-commonjs": "*", + "@rollup/plugin-node-resolve": "*", + "@rollup/plugin-terser": "*", + "@types/jasmine": "*", "cssnano": "^7.1.1", "eslint": "^9.28.0", "eslint-config-prettier": "^10.1.5", - "eslint-plugin-import": "latest", + "eslint-plugin-import": "*", "eslint-plugin-prettier": "^5.4.1", - "eslint-plugin-promise": "latest", - "express": "latest", + "eslint-plugin-promise": "*", + "express": "*", "globals": "^16.2.0", "husky": "^9.1.7", - "jasmine-core": "latest", - "npm-run-all": "latest", - "playwright": "latest", + "jasmine-core": "*", + "npm-run-all": "*", + "playwright": "*", "postcss": "^8.5.6", - "prettier": "latest", - "rollup": "latest", - "rollup-plugin-version-injector": "latest", + "prettier": "*", + "rollup": "*", + "rollup-plugin-version-injector": "*", "sinon": "19.0.4", - "typedoc": "latest", - "typescript": "latest", - "vite": "latest" + "typedoc": "*", + "typescript": "*", + "vite": "*" }, "engines": { "node": ">=24.0.0" @@ -3926,9 +3926,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/src/animations/animate-example.html b/src/animations/animate-example.html new file mode 100644 index 000000000..878706297 --- /dev/null +++ b/src/animations/animate-example.html @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/animations/animate.js b/src/animations/animate.js index b9b37b53d..fc9b67459 100644 --- a/src/animations/animate.js +++ b/src/animations/animate.js @@ -1,4 +1,10 @@ -import { isFunction, isObject, minErr, extend } from "../shared/utils.js"; +import { + isFunction, + isObject, + minErr, + extend, + mergeClasses, +} from "../shared/utils.js"; import { removeElement, animatedomInsert } from "../shared/dom.js"; import { NG_ANIMATE_CLASSNAME } from "./shared.js"; @@ -14,15 +20,6 @@ import { NG_ANIMATE_CLASSNAME } from "./shared.js"; const $animateMinErr = minErr("$animate"); -function mergeClasses(a, b) { - if (!a && !b) return ""; - if (!a) return b; - if (!b) return a; - if (Array.isArray(a)) a = a.join(" "); - if (Array.isArray(b)) b = b.join(" "); - return `${a} ${b}`; -} - // if any other type of options value besides an Object value is // passed into the $animate.method() animation then this helper code // will be run which will ignore it. While this patch is not the @@ -307,74 +304,12 @@ export function AnimateProvider($provide) { enabled: $$animateQueue.enabled, /** - * Cancels the provided animation and applies the end state of the animation. - * Note that this does not cancel the underlying operation, e.g. the setting of classes or - * adding the element to the DOM. - * - * @param {import('./runner/animate-runner.js').AnimateRunner} runner An animation runner returned by an $animate function. - * - * @example - - - angular.module('animationExample', []).component('cancelExample', { - templateUrl: 'template.html', - controller: function($element, $animate) { - this.runner = null; - - this.addClass = function() { - this.runner = $animate.addClass($element.querySelectorAll('div'), 'red'); - let ctrl = this; - this.runner.finally(function() { - ctrl.runner = null; - }); - }; - - this.removeClass = function() { - this.runner = $animate.removeClass($element.querySelectorAll('div'), 'red'); - let ctrl = this; - this.runner.finally(function() { - ctrl.runner = null; - }); - }; - - this.cancel = function() { - $animate.cancel(this.runner); - }; - } - }); - - -

- - -
- -
-

CSS-Animated Text
-

-
- - - - - .red-add, .red-remove { - transition: all 4s cubic-bezier(0.250, 0.460, 0.450, 0.940); - } - - .red, - .red-add.red-add-active { - color: #FF0000; - font-size: 40px; - } - - .red-remove.red-remove-active { - font-size: 10px; - color: black; - } - - -
- */ + * Cancels the provided animation and applies the end state of the animation. + * Note that this does not cancel the underlying operation, e.g. the setting of classes or + * adding the element to the DOM. + * + * @param {import('./runner/animate-runner.js').AnimateRunner} runner An animation runner returned by an $animate function. + */ cancel(runner) { if (runner.cancel) { runner.cancel(); diff --git a/src/animations/runner/animate-runner.js b/src/animations/runner/animate-runner.js index 0ec701def..673bd41ce 100644 --- a/src/animations/runner/animate-runner.js +++ b/src/animations/runner/animate-runner.js @@ -2,7 +2,7 @@ * @fileoverview * Frame-synchronized animation runner and scheduler. * Provides async batching of animation callbacks using requestAnimationFrame. - * In AngularJS, this user to be implemented as `$$AnimateRunner` + * In AngularJS, this used to be implemented as `$$AnimateRunner` */ /** diff --git a/src/core/compile/attributes.js b/src/core/compile/attributes.js index 301e624c8..79ef8571d 100644 --- a/src/core/compile/attributes.js +++ b/src/core/compile/attributes.js @@ -21,7 +21,7 @@ export class Attributes { static $nonscope = true; /** - * @param {ng.Scope} $rootScope + * @param {ng.RootScopeService} $rootScope * @param {*} $animate * @param {ng.ExceptionHandlerService} $exceptionHandler * @param {*} $sce diff --git a/src/core/compile/compile.js b/src/core/compile/compile.js index 1264cce70..bf2ac2846 100644 --- a/src/core/compile/compile.js +++ b/src/core/compile/compile.js @@ -2767,14 +2767,23 @@ export class CompileProvider { attr.$$element.classList.value, ); } else { - attr.$set(name, newValue); + attr.$set( + name, + name === "srcset" + ? $sce.getTrustedMediaUrl(newValue) + : newValue, + ); } }); }); if (interpolateFn.expressions.length == 0) { - // if there is nothing to watch, its a constant - attr.$set(name, newValue); + attr.$set( + name, + name === "srcset" + ? $sce.getTrustedMediaUrl(newValue) + : newValue, + ); } }, }; diff --git a/src/core/di/injector.js b/src/core/di/injector.js index a65fa7073..0b0212cf4 100644 --- a/src/core/di/injector.js +++ b/src/core/di/injector.js @@ -39,6 +39,7 @@ export function createInjector(modulesToLoad, strictDi = false) { service: supportObject(service), value: supportObject(value), constant: supportObject(constant), + wasm: supportObject(wasm), decorator, }, }; @@ -154,6 +155,17 @@ export function createInjector(modulesToLoad, strictDi = false) { protoInstanceInjector.cache[name] = value; } + /** + * Register a wasm module (available during config). + * @param {string} name + * @param {string} value + * @returns {void} + */ + function wasm(name, value) { + providerInjector.cache[name] = value; + protoInstanceInjector.cache[name] = value; + } + /** * Register a decorator function to modify or replace an existing service. * @param name - The name of the service to decorate. diff --git a/src/core/di/ng-module.js b/src/core/di/ng-module.js index 86203cc89..aedf85a4f 100644 --- a/src/core/di/ng-module.js +++ b/src/core/di/ng-module.js @@ -1,5 +1,10 @@ import { $injectTokens as $t } from "../../injection-tokens.js"; -import { isFunction, isString, assert } from "../../shared/utils.js"; +import { + isFunction, + isString, + assert, + instantiateWasm, +} from "../../shared/utils.js"; /** @private */ export const INJECTOR_LITERAL = "$injector"; @@ -60,6 +65,8 @@ export class NgModule { } this.services = []; + + this.wasmModules = []; } /** @@ -226,4 +233,18 @@ export class NgModule { this.invokeQueue.push([CONTROLLER_LITERAL, "register", [name, ctlFn]]); return this; } + + /** + * @param {string} name + * @param {string} src + * @returns {NgModule} + */ + wasm(name, src) { + this.invokeQueue.push([ + $t.$provide, + "wasm", + [name, (() => instantiateWasm(src))()], + ]); + return this; + } } diff --git a/src/core/interpolate/interpolate.spec.js b/src/core/interpolate/interpolate.spec.js index 51b2c2cc1..95bdec688 100644 --- a/src/core/interpolate/interpolate.spec.js +++ b/src/core/interpolate/interpolate.spec.js @@ -29,6 +29,14 @@ describe("$interpolate", () => { expect(interp({})).toEqual("why u no }}work{{"); }); + it("evaluates binary expressions", function () { + let interp = $interpolate("{{a + b}}"); + expect(interp({ a: 11, b: 22 })).toEqual("33"); + + interp = $interpolate("{{a + b + c}}"); + expect(interp({ a: 11, b: 22, c: 33 })).toEqual("66"); + }); + it("evaluates many expressions", function () { const interp = $interpolate("First {{anAttr}}, then {{anotherAttr}}!"); expect(interp({ anAttr: "42", anotherAttr: "43" })).toEqual( diff --git a/src/core/sanitize/interface.ts b/src/core/sanitize/interface.ts index fca01f011..0046947df 100644 --- a/src/core/sanitize/interface.ts +++ b/src/core/sanitize/interface.ts @@ -2,9 +2,7 @@ * Sanitizer function that processes a URI string and optionally * treats it as a media URL. */ -export interface SanitizerFn { - ( - uri: string | null | undefined, - isMediaUrl?: boolean, - ): string | null | undefined; -} +export type SanitizerFn = ( + uri: string | null | undefined, + isMediaUrl?: boolean, +) => string | null | undefined; diff --git a/src/core/sanitize/sanitize-uri.js b/src/core/sanitize/sanitize-uri.js index 90a8515a3..080c13eda 100644 --- a/src/core/sanitize/sanitize-uri.js +++ b/src/core/sanitize/sanitize-uri.js @@ -1,5 +1,5 @@ import { isDefined } from "../../shared/utils.js"; -import { urlResolve } from "../../shared/url-utils/url-utils.js"; +import { $injectTokens } from "../../injection-tokens.js"; /** @typedef {import('../../interface.ts').ServiceProvider} ServiceProvider */ @@ -53,23 +53,29 @@ export class SanitizeUriProvider { } /** - * @returns {import("./interface").SanitizerFn} + * @returns {import("./interface.ts").SanitizerFn} */ - $get() { - return (uri, isMediaUrl) => { - if (!uri) return uri; + $get = [ + $injectTokens.$window, + /** @param {ng.WindowService} $window */ + ($window) => { + return /** @type {import("./interface.ts").SanitizerFn} */ ( + (uri, isMediaUrl) => { + if (!uri) return uri; - /** @type {RegExp} */ - const regex = isMediaUrl - ? this._imgSrcSanitizationTrustedUrlList - : this._aHrefSanitizationTrustedUrlList; + /** @type {RegExp} */ + const regex = isMediaUrl + ? this._imgSrcSanitizationTrustedUrlList + : this._aHrefSanitizationTrustedUrlList; - const normalizedVal = urlResolve(uri.trim()).href; + const normalizedVal = new URL(uri.trim(), $window.location.href).href; - if (normalizedVal !== "" && !normalizedVal.match(regex)) { - return `unsafe:${normalizedVal}`; - } - return uri; - }; - } + if (normalizedVal !== "" && !normalizedVal.match(regex)) { + return `unsafe:${normalizedVal}`; + } + return uri; + } + ); + }, + ]; } diff --git a/src/core/sanitize/sanitize-uri.spec.js b/src/core/sanitize/sanitize-uri.spec.js index 919b4dbfa..35d6c81fd 100644 --- a/src/core/sanitize/sanitize-uri.spec.js +++ b/src/core/sanitize/sanitize-uri.spec.js @@ -8,7 +8,7 @@ describe("sanitizeUri", () => { let $$sanitizeUri; beforeEach(() => { sanitizeUriProvider = new SanitizeUriProvider(); - $$sanitizeUri = sanitizeUriProvider.$get(); + $$sanitizeUri = sanitizeUriProvider.$get[1](window); sanitizeHref = function (uri) { return $$sanitizeUri(uri, false); diff --git a/src/core/scope/interface.ts b/src/core/scope/interface.ts index 360eaeeab..6626df9b8 100644 --- a/src/core/scope/interface.ts +++ b/src/core/scope/interface.ts @@ -7,11 +7,11 @@ export interface AsyncQueueTask { locals: Record; } -export type ListenerFunction = (newValue: any, originalTarget: object) => void; +export type ListenerFn = (newValue?: any, originalTarget?: object) => void; export interface Listener { originalTarget: object; - listenerFn: ListenerFunction; + listenerFn: ListenerFn; watchFn: CompiledExpression; id: number; // Deregistration id scopeId: number; // The scope id that created the Listener diff --git a/src/core/scope/scope.js b/src/core/scope/scope.js index c6c1215e4..80c28542d 100644 --- a/src/core/scope/scope.js +++ b/src/core/scope/scope.js @@ -136,6 +136,15 @@ export function isUnsafeGlobal(target) { return true; } + if (target instanceof Promise) { + return true; + } + + // Events + if (typeof Event !== "undefined" && target instanceof Event) { + return true; + } + // Cross-origin or non-enumerable window objects try { return Object.prototype.toString.call(target) === "[object Window]"; @@ -616,7 +625,7 @@ export class Scope { * function is invoked when changes to that property are detected. * * @param {string} watchProp - An expression to be watched in the context of this model. - * @param {import('./interface.ts').ListenerFunction} [listenerFn] - A function to execute when changes are detected on watched context. + * @param {ng.ListenerFn} [listenerFn] - A function to execute when changes are detected on watched context. * @param {boolean} [lazy] - A flag to indicate if the listener should be invoked immediately. Defaults to false. */ $watch(watchProp, listenerFn, lazy = false) { @@ -638,7 +647,7 @@ export class Scope { return () => {}; } - /** @type {import('./interface.ts').Listener} */ + /** @type {ng.Listener} */ const listener = { originalTarget: this.$target, listenerFn: listenerFn, @@ -696,13 +705,34 @@ export class Scope { } // 6 case ASTType.BinaryExpression: { - let expr = get.decoratedNode.body[0].expression.toWatch[0]; - key = expr.property ? expr.property.name : expr.name; - if (!key) { - throw new Error("Unable to determine key"); + if (get.decoratedNode.body[0].expression.isPure) { + let expr = get.decoratedNode.body[0].expression.toWatch[0]; + key = expr.property ? expr.property.name : expr.name; + if (!key) { + throw new Error("Unable to determine key"); + } + listener.property.push(key); + break; + } else { + let keys = []; + get.decoratedNode.body[0].expression.toWatch.forEach((x) => { + const k = x.property ? x.property.name : x.name; + if (!k) { + throw new Error("Unable to determine key"); + } + keys.push(k); + }); + keys.forEach((key) => { + this.#registerKey(key, listener); + this.#scheduleListener([listener]); + }); + + return () => { + keys.forEach((key) => { + this.#deregisterKey(key, listener.id); + }); + }; } - listener.property.push(key); - break; } // 7 case ASTType.UnaryExpression: { diff --git a/src/core/scope/scope.spec.js b/src/core/scope/scope.spec.js index 3aa9ff7f1..c2592b294 100644 --- a/src/core/scope/scope.spec.js +++ b/src/core/scope/scope.spec.js @@ -592,7 +592,7 @@ describe("Scope", () => { expect(scope.counter).toBe(4); }); - it("calls only the listeners registerred at the moment the watched value changes", async () => { + it("calls only the listeners registered at the moment the watched value changes", async () => { scope.someValue = "a"; scope.counter = 0; @@ -823,7 +823,7 @@ describe("Scope", () => { expect(scope.$$watchersCount).toEqual(scope.$handler.watchers.size); }); - it("should fire upon $watch registration on initial registeration", async () => { + it("should fire upon $watch registration on initial registration", async () => { logs = ""; scope.a = 1; scope.$watch("a", () => { @@ -3058,4 +3058,8 @@ describe("isUnsafeGlobal", () => { }; expect(() => isUnsafeGlobal(fakeCrossOrigin)).not.toThrow(); }); + + it("ignores events", () => { + expect(isUnsafeGlobal(new PointerEvent("test"))).toBeTrue(); + }); }); diff --git a/src/directive/http/http.js b/src/directive/http/http.js index d5ccbea40..7ca44b570 100644 --- a/src/directive/http/http.js +++ b/src/directive/http/http.js @@ -77,6 +77,7 @@ export function createHttpDirective(method, attrName) { * @param {ng.ParseService} $parse * @param {ng.StateService} $state * @param {ng.SseService} $sse + * @param {*} $animate * @returns {ng.Directive} */ return function ($http, $compile, $log, $parse, $state, $sse, $animate) { diff --git a/src/directive/wasm/math.ts b/src/directive/wasm/math.ts new file mode 100644 index 000000000..acaba61a1 --- /dev/null +++ b/src/directive/wasm/math.ts @@ -0,0 +1,8 @@ +// // math.ts +// export function add(a: i32, b: i32): i32 { +// return a + b; +// } + +// export function mul(a: i32, b: i32): i32 { +// return a * b; +// } diff --git a/src/directive/wasm/math.wasm b/src/directive/wasm/math.wasm new file mode 100644 index 000000000..cf925020a Binary files /dev/null and b/src/directive/wasm/math.wasm differ diff --git a/src/directive/wasm/wasm.js b/src/directive/wasm/wasm.js new file mode 100644 index 000000000..de7ff8569 --- /dev/null +++ b/src/directive/wasm/wasm.js @@ -0,0 +1,9 @@ +import { instantiateWasm } from "../../shared/utils.js"; + +export function ngWasmDirective() { + return { + link: async function ($scope, _, $attrs) { + $scope.$target[$attrs.as || "wasm"] = await instantiateWasm($attrs.src); + }, + }; +} diff --git a/src/namespace.ts b/src/namespace.ts index 3e8a5b3b4..dd4f76e00 100644 --- a/src/namespace.ts +++ b/src/namespace.ts @@ -5,6 +5,10 @@ export { angular } from "./index.js"; import { Angular as TAngular } from "./angular.js"; import { Attributes as TAttributes } from "./core/compile/attributes.js"; import { Scope as TScope } from "./core/scope/scope.js"; +import { + ListenerFn as TListenerFn, + Listener as TListener, +} from "./core/scope/interface.ts"; import { NgModule as TNgModule } from "./core/di/ng-module.js"; import { InjectorService as TInjectorService } from "./core/di/internal-injector.js"; @@ -130,8 +134,10 @@ declare global { // Support types export type ErrorHandlingConfig = TErrorHandlingConfig; - export type WindowService = Window; + export type ListenerFn = TListenerFn; + export type Listener = TListener; export type DocumentService = Document; + export type WindowService = Window; export type WorkerConfig = TWorkerConfig; export type WorkerConnection = TWorkerConnection; } diff --git a/src/ng.js b/src/ng.js index 6f1cb05ca..1e91d999b 100644 --- a/src/ng.js +++ b/src/ng.js @@ -135,6 +135,7 @@ import { SseProvider } from "./services/sse/sse.js"; import { ngViewportDirective } from "./directive/viewport/viewport.js"; import { ngWorkerDirective } from "./directive/worker/worker.js"; import { WorkerProvider } from "./services/worker/worker.js"; +import { ngWasmDirective } from "./directive/wasm/wasm.js"; /** * Initializes core `ng` module. @@ -215,6 +216,7 @@ export function registerNgModule(angular) { ngValue: ngValueDirective, ngModelOptions: ngModelOptionsDirective, ngViewport: ngViewportDirective, + ngWasm: ngWasmDirective, ngWorker: ngWorkerDirective, }) .directive({ diff --git a/src/router/state/state.spec.js b/src/router/state/state.spec.js index 30c73eba2..ad6829076 100644 --- a/src/router/state/state.spec.js +++ b/src/router/state/state.spec.js @@ -118,7 +118,7 @@ describe("$state", () => { expect($stateProvider).toBeDefined(); }); - it("should should not allow states that are already registerred", () => { + it("should should not allow states that are already registered", () => { expect(() => { $stateProvider.state({ name: "toString", url: "/to-string" }); }).not.toThrow(); diff --git a/src/services/sce/sce.js b/src/services/sce/sce.js index 74cd62ebe..4c6a5e9d5 100644 --- a/src/services/sce/sce.js +++ b/src/services/sce/sce.js @@ -88,7 +88,7 @@ export function adjustMatcher(matcher) { * `$sceDelegate` is a service that is used by the `$sce` service to provide {@link ng.$sce Strict * Contextual Escaping (SCE)} services to AngularTS. * - * For an overview of this service and the functionnality it provides in AngularTS, see the main + * For an overview of this service and the functionality it provides in AngularTS, see the main * page for {@link ng.$sce SCE}. The current page is targeted for developers who need to alter how * SCE works in their application, which shouldn't be needed in most cases. * @@ -115,85 +115,6 @@ export function adjustMatcher(matcher) { * ng.$sceDelegateProvider#bannedResourceUrlList $sceDelegateProvider.bannedResourceUrlList} */ -/** - * - * The `$sceDelegateProvider` provider allows developers to configure the {@link ng.$sceDelegate - * $sceDelegate service}, used as a delegate for {@link ng.$sce Strict Contextual Escaping (SCE)}. - * - * The `$sceDelegateProvider` allows one to get/set the `trustedResourceUrlList` and - * `bannedResourceUrlList` used to ensure that the URLs used for sourcing AngularTS templates and - * other script-running URLs are safe (all places that use the `$sce.RESOURCE_URL` context). See - * {@link ng.$sceDelegateProvider#trustedResourceUrlList - * $sceDelegateProvider.trustedResourceUrlList} and - * {@link ng.$sceDelegateProvider#bannedResourceUrlList $sceDelegateProvider.bannedResourceUrlList}, - * - * For the general details about this service in AngularTS, read the main page for {@link ng.$sce - * Strict Contextual Escaping (SCE)}. - * - * **Example**: Consider the following case. - * - * - your app is hosted at url `http://myapp.example.com/` - * - but some of your templates are hosted on other domains you control such as - * `http://srv01.assets.example.com/`, `http://srv02.assets.example.com/`, etc. - * - and you have an open redirect at `http://myapp.example.com/clickThru?...`. - * - * Here is what a secure configuration for this scenario might look like: - * - * ``` - * angular.module('myApp', []).config(function($sceDelegateProvider) { - * $sceDelegateProvider.trustedResourceUrlList([ - * // Allow same origin resource loads. - * 'self', - * // Allow loading from our assets domain. Notice the difference between * and **. - * 'http://srv*.assets.example.com/**' - * ]); - * - * // The banned resource URL list overrides the trusted resource URL list so the open redirect - * // here is blocked. - * $sceDelegateProvider.bannedResourceUrlList([ - * 'http://myapp.example.com/clickThru**' - * ]); - * }); - * ``` - * Note that an empty trusted resource URL list will block every resource URL from being loaded, and will require - * you to manually mark each one as trusted with `$sce.trustAsResourceUrl`. However, templates - * requested by {@link ng.$templateRequest $templateRequest} that are present in - * {@link ng.$templateCache $templateCache} will not go through this check. If you have a mechanism - * to populate your templates in that cache at config time, then it is a good idea to remove 'self' - * from the trusted resource URL lsit. This helps to mitigate the security impact of certain types - * of issues, like for instance attacker-controlled `ng-includes`. - */ - -/** - * `$sceDelegate` is a service that is used by the `$sce` service to provide {@link ng.$sce Strict - * Contextual Escaping (SCE)} services to AngularTS. - * - * For an overview of this service and the functionnality it provides in AngularTS, see the main - * page for {@link ng.$sce SCE}. The current page is targeted for developers who need to alter how - * SCE works in their application, which shouldn't be needed in most cases. - * - *
- * AngularTS strongly relies on contextual escaping for the security of bindings: disabling or - * modifying this might cause cross site scripting (XSS) vulnerabilities. For libraries owners, - * changes to this service will also influence users, so be extra careful and document your changes. - *
- * - * Typically, you would configure or override the {@link ng.$sceDelegate $sceDelegate} instead of - * the `$sce` service to customize the way Strict Contextual Escaping works in AngularTS. This is - * because, while the `$sce` provides numerous shorthand methods, etc., you really only need to - * override 3 core functions (`trustAs`, `getTrusted` and `valueOf`) to replace the way things - * work because `$sce` delegates to `$sceDelegate` for these operations. - * - * Refer {@link ng.$sceDelegateProvider $sceDelegateProvider} to configure this service. - * - * The default instance of `$sceDelegate` should work out of the box with little pain. While you - * can override it completely to change the behavior of `$sce`, the common case would - * involve configuring the {@link ng.$sceDelegateProvider $sceDelegateProvider} instead by setting - * your own trusted and banned resource lists for trusting URLs used for loading AngularTS resources - * such as templates. Refer {@link ng.$sceDelegateProvider#trustedResourceUrlList - * $sceDelegateProvider.trustedResourceUrlList} and {@link - * ng.$sceDelegateProvider#bannedResourceUrlList $sceDelegateProvider.bannedResourceUrlList} - */ /** * * The `$sceDelegateProvider` provider allows developers to configure the {@link ng.$sceDelegate @@ -250,7 +171,7 @@ export class SceDelegateProvider { /** * - * @param {Array=} trustedResourceUrlList When provided, replaces the trustedResourceUrlList with + * @param {Array=} value When provided, replaces the trustedResourceUrlList with * the value provided. This must be an array or null. A snapshot of this array is used so * further changes to the array are ignored. * Follow {@link ng.$sce#resourceUrlPatternItem this link} for a description of the items @@ -312,7 +233,7 @@ export class SceDelegateProvider { /** * * @param {ng.InjectorService} $injector - * @param {*} $$sanitizeUri + * @param {import("../../core/sanitize/interface.ts").SanitizerFn} $$sanitizeUri * @param {ng.ExceptionHandlerService} $exceptionHandler * @returns */ diff --git a/src/services/sse/sse.js b/src/services/sse/sse.js index 9c22c631a..c9ca0520b 100644 --- a/src/services/sse/sse.js +++ b/src/services/sse/sse.js @@ -134,7 +134,7 @@ export class SseProvider { es.close(); }, connect() { - if (closed == false) { + if (closed === false) { close(); } connect(); diff --git a/src/shared/noderef.js b/src/shared/noderef.js index 06de2e36e..ac21e1734 100644 --- a/src/shared/noderef.js +++ b/src/shared/noderef.js @@ -67,8 +67,8 @@ export class NodeRef { } // Handle array of elements - else if (element instanceof Array) { - if (element.length == 1) { + else if (Array.isArray(element)) { + if (element.length === 1) { this.initial = element[0].cloneNode(true); this.node = element[0]; } else { @@ -129,19 +129,12 @@ export class NodeRef { /** @returns {NodeList|Node[]} */ get nodelist() { - assertArg(this.isList, "nodes"); - if (this._nodes.length === 0) { - return this._nodes; - } - if (this._nodes[0].parentElement) { + if (this._nodes.length === 0) return []; + if (this._nodes[0].parentElement) return this._nodes[0].parentElement.childNodes; - } else { - const fragment = document.createDocumentFragment(); - this._nodes.forEach((el) => { - fragment.appendChild(el); - }); - return fragment.childNodes; - } + const fragment = document.createDocumentFragment(); + this._nodes.forEach((el) => fragment.appendChild(el)); + return fragment.childNodes; } /** @returns {Element | Node | ChildNode | NodeList | Node[]} */ diff --git a/src/shared/noderef.spec.js b/src/shared/noderef.spec.js new file mode 100644 index 000000000..25745faf6 --- /dev/null +++ b/src/shared/noderef.spec.js @@ -0,0 +1,187 @@ +import { NodeRef } from "./noderef.js"; + +describe("NodeRef", () => { + let div; + let span; + let textNode; + + beforeEach(() => { + div = document.createElement("div"); + div.textContent = "hello"; + span = document.createElement("span"); + span.textContent = "world"; + textNode = document.createTextNode("plain text"); + }); + + describe("constructor", () => { + it("wraps a single Element", () => { + const ref = new NodeRef(div); + expect(ref.node).toBe(div); + expect(ref.element).toBe(div); + expect(ref.size).toBe(1); + expect(ref.isList).toBeFalse(); + expect(ref.isElement()).toBeTrue(); + }); + + it("wraps a single Node", () => { + const ref = new NodeRef(textNode); + expect(ref.node).toBe(textNode); + expect(ref.isElement()).toBeFalse(); + }); + + it("wraps an array of Nodes", () => { + const ref = new NodeRef([div, span]); + expect(ref.nodes).toEqual([div, span]); + expect(ref.isList).toBeTrue(); + expect(ref.size).toBe(2); + }); + + it("wraps an array with one Node", () => { + const ref = new NodeRef([div]); + expect(ref.node).toBe(div); + expect(ref.isList).toBeFalse(); + expect(ref.size).toBe(1); + }); + + it("wraps a NodeList", () => { + const parent = document.createElement("div"); + parent.append(div, span); + const ref = new NodeRef(parent.childNodes); + expect(ref.nodes).toEqual([div, span]); + expect(ref.isList).toBeTrue(); + }); + + it("wraps an HTML string", () => { + const html = "

hi

"; + const ref = new NodeRef(html); + expect(ref.element.tagName.toLowerCase()).toBe("p"); + expect(ref.element.textContent).toBe("hi"); + }); + + it("handles HTML string with multiple nodes", () => { + const html = "
A
B"; + const ref = new NodeRef(html); + expect(ref.getAny().outerHTML).toContain("div"); + }); + + it("throws on invalid input", () => { + expect(() => new NodeRef(123)).toThrowError( + "Invalid element passed to NodeRef", + ); + expect(() => new NodeRef(null)).toThrow(); + expect(() => new NodeRef(undefined)).toThrow(); + }); + }); + + describe("getters and setters", () => { + it("sets and gets node", () => { + const ref = new NodeRef(div); + const newDiv = document.createElement("div"); + ref.node = newDiv; + expect(ref.node).toBe(newDiv); + expect(ref.element).toBe(newDiv); + }); + + it("sets and gets nodes array", () => { + const ref = new NodeRef(div); + ref.nodes = [div, span]; + expect(ref.nodes).toEqual([div, span]); + expect(ref.isList).toBeTrue(); + }); + + it("returns correct element and node", () => { + const ref = new NodeRef(div); + expect(ref.element).toBe(div); + expect(ref.node).toBe(div); + }); + + it("returns size correctly", () => { + const ref1 = new NodeRef(div); + const ref2 = new NodeRef([div, span]); + expect(ref1.size).toBe(1); + expect(ref2.size).toBe(2); + }); + }); + + describe("list and collection methods", () => { + it("getAny() returns first node of list", () => { + const ref = new NodeRef([div, span]); + expect(ref.getAny()).toBe(div); + }); + + it("getAll() returns all nodes", () => { + const ref = new NodeRef([div, span]); + expect(ref.getAll()).toEqual([div, span]); + }); + + it("collection() always returns array", () => { + const ref1 = new NodeRef(div); + const ref2 = new NodeRef([div, span]); + expect(ref1.collection()).toEqual([div]); + expect(ref2.collection()).toEqual([div, span]); + }); + + it("getIndex() and setIndex() work properly", () => { + const ref = new NodeRef([div, span]); + expect(ref.getIndex(1)).toBe(span); + const newNode = document.createElement("p"); + ref.setIndex(1, newNode); + expect(ref.getIndex(1)).toBe(newNode); + }); + }); + + describe("nodelist and clone", () => { + it("returns live nodelist if attached to DOM", () => { + const parent = document.createElement("div"); + parent.append(div, span); + const ref = new NodeRef([div, span]); + const list = ref.nodelist; + expect(list[0]).toBe(div); + expect(list[1]).toBe(span); + }); + + it("returns fragment-based nodelist if detached", () => { + const ref = new NodeRef([div, span]); + const list = ref.nodelist; + expect(list.length).toBe(2); + expect(list[0].isEqualNode(div)).toBeTrue(); + }); + + it("clone() creates deep copy", () => { + const ref = new NodeRef([div, span]); + const clone = ref.clone(); + expect(clone.nodes[0].isEqualNode(div)).toBeTrue(); + expect(clone.nodes[0]).not.toBe(div); + }); + + it("clone() works on single element", () => { + const ref = new NodeRef(div); + const clone = ref.clone(); + expect(clone.node.isEqualNode(div)).toBeTrue(); + expect(clone.node).not.toBe(div); + }); + }); + + describe("edge cases", () => { + it("handles text node only", () => { + const ref = new NodeRef(textNode); + expect(ref.node.textContent).toBe("plain text"); + expect(ref.isElement()).toBeFalse(); + }); + + it("handles DocumentFragment", () => { + const frag = document.createDocumentFragment(); + frag.append(div, span); + const ref = new NodeRef(frag.childNodes); + expect(ref.isList).toBeTrue(); + expect(ref.nodes.length).toBe(2); + }); + + it("handles malformed HTML string gracefully", () => { + const html = "
Missing close"; + const ref = new NodeRef(html); + expect(ref.node).toBeTruthy(); + expect(ref.getAny().nodeType).toBe(Node.ELEMENT_NODE); + }); + }); +}); diff --git a/src/shared/shared.html b/src/shared/shared.html index ca51488d5..d3be5190f 100644 --- a/src/shared/shared.html +++ b/src/shared/shared.html @@ -14,6 +14,7 @@ + diff --git a/src/shared/utils.js b/src/shared/utils.js index df2768afd..1e8fcd724 100644 --- a/src/shared/utils.js +++ b/src/shared/utils.js @@ -1106,13 +1106,38 @@ export function hashKey(obj) { return `${objType}:${obj}`; } +/** + * Merges two class name values into a single space-separated string. + * Accepts strings, arrays of strings, or null/undefined values. + * + * @param {string | string[] | null | undefined} a - The first class name(s). + * @param {string | string[] | null | undefined} b - The second class name(s). + * @returns {string} A single string containing all class names separated by spaces. + */ export function mergeClasses(a, b) { if (!a && !b) return ""; - if (!a) return b; - if (!b) return a; - if (Array.isArray(a)) a = a.join(" "); - if (Array.isArray(b)) b = b.join(" "); - return a + " " + b; + if (!a) return Array.isArray(b) ? b.join(" ").trim() : b; + if (!b) return Array.isArray(a) ? a.join(" ").trim() : a; + if (Array.isArray(a)) a = normalizeStringArray(a); + if (Array.isArray(b)) b = normalizeStringArray(b); + return `${a.trim()} ${b.trim()}`.trim(); +} + +/** + * Joins an array of strings into a single string, trimming each + * element and ignoring empty strings, null, and undefined + * @param {any[]} arr + * @returns {string} + */ +function normalizeStringArray(arr) { + const cleaned = []; + for (const item of arr) { + if (item) { + const trimmed = item.trim(); + if (trimmed) cleaned.push(trimmed); + } + } + return cleaned.join(" "); } /** @@ -1253,3 +1278,21 @@ export function wait(t = 0) { export function startsWith(str, search) { return str.slice(0, search.length) === search; } + +/** + * Loads and instantiates a WebAssembly module with proper error handling. + * + * @param {string} src - URL to the wasm file + * @returns {Promise} - Resolves to wasm exports + */ +export async function instantiateWasm(src) { + try { + const r = await fetch(src); + if (!r.ok) throw new Error(`${r}`); + const bytes = await r.arrayBuffer(); + const { instance } = await WebAssembly.instantiate(bytes); + return instance.exports; + } catch (e) { + throw new Error(e); + } +} diff --git a/src/shared/utils.spec.js b/src/shared/utils.spec.js index 1d6c65361..4dff379c5 100644 --- a/src/shared/utils.spec.js +++ b/src/shared/utils.spec.js @@ -1,4 +1,4 @@ -import { hashKey, startsWith } from "./utils.js"; +import { hashKey, mergeClasses, startsWith } from "./utils.js"; describe("api", () => { describe("hashKey()", () => { @@ -175,4 +175,72 @@ describe("api", () => { expect(startsWith("short", "shorter")).toBe(false); }); }); + + describe("mergeClasses", () => { + it("should return empty string if both arguments are null or undefined", () => { + expect(mergeClasses(null, null)).toBe(""); + expect(mergeClasses(undefined, undefined)).toBe(""); + expect(mergeClasses(null, undefined)).toBe(""); + }); + + it("should return first argument if second is null/undefined", () => { + expect(mergeClasses("btn", null)).toBe("btn"); + expect(mergeClasses(["btn", "primary"], undefined)).toBe("btn primary"); + }); + + it("should return second argument if first is null/undefined", () => { + expect(mergeClasses(null, "active")).toBe("active"); + expect(mergeClasses(undefined, ["active", "large"])).toBe("active large"); + }); + + it("should merge two strings with a space", () => { + expect(mergeClasses("btn", "active")).toBe("btn active"); + }); + + it("should always trim merged strings", () => { + expect(mergeClasses("btn ", " active")).toBe("btn active"); + }); + + it("should merge two arrays of strings", () => { + expect(mergeClasses(["btn", "primary"], ["active", "large"])).toBe( + "btn primary active large", + ); + }); + + it("should merge string and array", () => { + expect(mergeClasses("btn", ["active", "large"])).toBe("btn active large"); + expect(mergeClasses(["btn", "primary"], "active")).toBe( + "btn primary active", + ); + }); + + it("should ignore empty strings, null, and undefined in arrays", () => { + expect( + mergeClasses(["btn", "", null, undefined], ["active", "", null]), + ).toBe("btn active"); + }); + + it("should ignore empty strings, null, and undefined in arrays if second argument is empty", () => { + expect(mergeClasses(["btn", "", null, undefined], undefined)).toBe("btn"); + }); + + it("should ignore empty strings, null, and undefined in arrays if first argument is empty", () => { + expect(mergeClasses(undefined, ["btn", "", null, undefined])).toBe("btn"); + }); + + it("should handle single argument arrays correctly", () => { + expect(mergeClasses(["btn"], ["active"])).toBe("btn active"); + }); + + it("should handle single argument arrays correctly", () => { + expect(mergeClasses(["btn ", " test"], [" active"])).toBe( + "btn test active", + ); + }); + + it("should handle one argument as string and the other as empty array", () => { + expect(mergeClasses("btn", [])).toBe("btn"); + expect(mergeClasses([], "active")).toBe("active"); + }); + }); });