From 73eca80daa35712906493a8d996b1c85e21df018 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 10 Aug 2026 16:12:10 -0300 Subject: [PATCH 1/4] feat: WHATWG performance API (hr-time, user timing, performance timeline) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the bare {now, timeOrigin} object with a spec-shaped implementation of hr-time, User Timing Level 3 and the performance timeline with PerformanceObserver, exposing performance, Performance, PerformanceEntry, PerformanceMark, PerformanceMeasure, PerformanceObserver and PerformanceObserverEntryList as globals in main and worker isolates alike. * feat: internal/performance.js builtin carrying all spec logic — classes, entry buffers, observer registry with microtask-batched dispatch routed to reportError. The native side hands it only {now(), timeOrigin}, so the builtin is portable and is intended to be reused unchanged by the Android runtime against an equivalent binding bag. * feat: tns::Performance native shim (Performance.h/.cpp) run post-context after Events/ErrorEvents (Performance extends EventTarget); Performance::NowMillis(isolate) is the single native clock hook that future requestAnimationFrame work must share so every JS-visible timestamp has performance.timeOrigin as its base. * refactor: time-origin members on Runtime renamed (timeOriginMonotonic_/timeOriginRealtimeMs_) with public accessors; DefinePerformanceObject/PerformanceNowCallback removed. Origins are still captured per Runtime in CreateIsolate, so each worker keeps its own timeOrigin. * test: shared cross-runtime suite (53 specs) added to common-runtime-tests-app as runPerformanceTests (NativeScript/ common-runtime-tests-app#25), opted into from the iOS test index; the old Performance block in RuntimeImplementedAPIs.js moved there. * docs: docs/performance.md — surface, architecture and the documented deviations (by-reference detail, microtask observer dispatch, Error-with-name instead of DOMException, unbounded buffers). performance.now() keeps full double precision (no coarsening), on the V8 platform monotonic clock (mach_absolute_time base, shared with CACurrentMediaTime/CADisplayLink). The specs cover hr-time invariants, the measure() options algebra, observer delivery ordering, and worker time-origin/buffer isolation; full TestRunner suite green (0 failures). --- NativeScript/runtime/Performance.cpp | 63 ++ NativeScript/runtime/Performance.h | 37 ++ NativeScript/runtime/Runtime.h | 26 +- NativeScript/runtime/Runtime.mm | 26 +- NativeScript/runtime/js/performance.js | 577 ++++++++++++++++++ NativeScript/runtime/js/primordials.js | 3 + TestRunner/app/shared | 2 +- .../app/tests/RuntimeImplementedAPIs.js | 28 - TestRunner/app/tests/index.js | 3 + docs/README.md | 4 + docs/performance.md | 75 +++ eslint.config.mjs | 1 + tools/js2c-inputs.xcfilelist | 1 + v8ios.xcodeproj/project.pbxproj | 6 + 14 files changed, 797 insertions(+), 55 deletions(-) create mode 100644 NativeScript/runtime/Performance.cpp create mode 100644 NativeScript/runtime/Performance.h create mode 100644 NativeScript/runtime/js/performance.js create mode 100644 docs/performance.md diff --git a/NativeScript/runtime/Performance.cpp b/NativeScript/runtime/Performance.cpp new file mode 100644 index 00000000..7167eb3e --- /dev/null +++ b/NativeScript/runtime/Performance.cpp @@ -0,0 +1,63 @@ +#include "Performance.h" + +#include "BuiltinLoader.h" +#include "Helpers.h" +#include "Runtime.h" + +using namespace v8; + +namespace tns { + +void Performance::Init(v8::Local context) { + Isolate* isolate = v8::Isolate::GetCurrent(); + + // kThrow so `new performance.now()` throws, as WebIDL operations must; + // kHasNoSideEffect so the debugger can call it during side-effect-free + // evaluation. + Local now; + bool success = v8::Function::New(context, NowCallback, Local(), 0, + ConstructorBehavior::kThrow, + SideEffectType::kHasNoSideEffect) + .ToLocal(&now); + tns::Assert(success, isolate); + + Local binding = Object::New(isolate); + success = binding->Set(context, tns::ToV8String(isolate, "now"), now) + .FromMaybe(false); + tns::Assert(success, isolate); + + success = binding + ->Set(context, tns::ToV8String(isolate, "timeOrigin"), + v8::Number::New(isolate, TimeOriginMillis(isolate))) + .FromMaybe(false); + tns::Assert(success, isolate); + + Local result; + success = BuiltinLoader::RunBuiltin(context, BuiltinId::kPerformance, binding) + .ToLocal(&result); + tns::Assert(success, isolate); +} + +double Performance::NowMillis(Isolate* isolate) { + Runtime* runtime = Runtime::GetRuntime(isolate); + if (runtime == nullptr) { + return 0.0; + } + + return runtime->PerformanceNowMillis(); +} + +double Performance::TimeOriginMillis(Isolate* isolate) { + Runtime* runtime = Runtime::GetRuntime(isolate); + if (runtime == nullptr) { + return 0.0; + } + + return runtime->TimeOriginMillis(); +} + +void Performance::NowCallback(const FunctionCallbackInfo& info) { + info.GetReturnValue().Set(NowMillis(info.GetIsolate())); +} + +} // namespace tns diff --git a/NativeScript/runtime/Performance.h b/NativeScript/runtime/Performance.h new file mode 100644 index 00000000..41a1fed5 --- /dev/null +++ b/NativeScript/runtime/Performance.h @@ -0,0 +1,37 @@ +#ifndef Performance_h +#define Performance_h + +#include "Common.h" + +namespace tns { + +class Performance { + public: + // Installs the WHATWG Performance API by evaluating internal/performance.js + // with a bag of natives {now, timeOrigin}. Must run after Events::Init + // (Performance extends EventTarget) and after ErrorEvents::Init (observer + // failures are reported through it). Evaluated once per isolate during + // Runtime::Init, for main and worker isolates alike; each isolate carries its + // own time origin. + static void Init(v8::Local context); + + // Milliseconds elapsed on this isolate's performance timeline: monotonic and + // zero at the time origin. Native producers of JS-visible timestamps (a + // future requestAnimationFrame) must read the clock through here instead of + // sampling one of their own, so every such timestamp shares + // performance.timeOrigin as its base. Returns 0.0 for an isolate with no + // runtime. + static double NowMillis(v8::Isolate* isolate); + + // Wall-clock milliseconds since the Unix epoch at the isolate's time origin, + // on the same base as Date.now(); this is performance.timeOrigin. Returns + // 0.0 for an isolate with no runtime. + static double TimeOriginMillis(v8::Isolate* isolate); + + private: + static void NowCallback(const v8::FunctionCallbackInfo& info); +}; + +} // namespace tns + +#endif /* Performance_h */ diff --git a/NativeScript/runtime/Runtime.h b/NativeScript/runtime/Runtime.h index f8e1d677..d6f3a5ac 100644 --- a/NativeScript/runtime/Runtime.h +++ b/NativeScript/runtime/Runtime.h @@ -55,6 +55,24 @@ class Runtime { static bool IsAlive(const v8::Isolate* isolate); + // Milliseconds since this runtime's time origin, on the monotonic clock. + // Not inline on purpose: an inline definition would have to reach the + // platform through GetPlatform(), which copies a shared_ptr on every call, + // while the out-of-line definition reads platform_ directly. + double PerformanceNowMillis(); + + // Wall-clock milliseconds since the Unix epoch at the moment the time origin + // was captured, on the same base as Date.now(); this is + // performance.timeOrigin. + inline double TimeOriginMillis() const { return timeOriginRealtimeMs_; } + + // The monotonic clock reading (seconds, V8 platform units) of the time + // origin, for mapping platform-supplied timestamps onto the performance + // timeline. + inline double TimeOriginMonotonicSeconds() const { + return timeOriginMonotonic_; + } + private: static thread_local Runtime* currentRuntime_; static std::shared_ptr platform_; @@ -67,8 +85,6 @@ class Runtime { void DefineCollectFunction(v8::Local context); void DefineNativeScriptVersion(v8::Isolate* isolate, v8::Local globalTemplate); - void DefinePerformanceObject(v8::Isolate* isolate, - v8::Local globalTemplate); void DefineTimeMethod(v8::Isolate* isolate, v8::Local globalTemplate); void DefineDrainMicrotaskMethod(v8::Isolate* isolate, @@ -76,8 +92,6 @@ class Runtime { void DefineDateTimeConfigurationChangeNotificationMethod( v8::Isolate* isolate, v8::Local globalTemplate); - static void PerformanceNowCallback( - const v8::FunctionCallbackInfo& args); static void DrainRejectionsObserver(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void* info); v8::Isolate* isolate_; @@ -87,8 +101,8 @@ class Runtime { // Drains unhandled promise rejections once per runloop turn // (kCFRunLoopBeforeWaiting). Torn down before isolate disposal in ~Runtime. CFRunLoopObserverRef rejectionObserver_ = nullptr; - double startTime; - double realtimeOrigin; + double timeOriginMonotonic_; + double timeOriginRealtimeMs_; // TODO: refactor this. This is only needed because, during program // termination (UIApplicationMain not called) the Cache::Workers is released // (static initialization order fiasco diff --git a/NativeScript/runtime/Runtime.mm b/NativeScript/runtime/Runtime.mm index c7fdead9..4b9391ea 100644 --- a/NativeScript/runtime/Runtime.mm +++ b/NativeScript/runtime/Runtime.mm @@ -13,6 +13,7 @@ #include "Interop.h" #include "NativeScriptException.h" #include "ObjectManager.h" +#include "Performance.h" #include "PromiseProxy.h" #include "RuntimeConfig.h" #include "SimpleAllocator.h" @@ -274,8 +275,8 @@ void DisposeIsolateWhenPossible(Isolate* isolate) { v8Initialized_ = true; } - startTime = platform_->MonotonicallyIncreasingTime(); - realtimeOrigin = platform_->CurrentClockTimeMillis(); + timeOriginMonotonic_ = platform_->MonotonicallyIncreasingTime(); + timeOriginRealtimeMs_ = platform_->CurrentClockTimeMillis(); // auto version = v8::V8::GetVersion(); @@ -309,7 +310,6 @@ void DisposeIsolateWhenPossible(Isolate* isolate) { DefineNativeScriptVersion(isolate, globalTemplate); // Worker::Init(isolate, globalTemplate, isWorker); - DefinePerformanceObject(isolate, globalTemplate); DefineTimeMethod(isolate, globalTemplate); DefineDrainMicrotaskMethod(isolate, globalTemplate); // queueMicrotask(callback) per spec @@ -362,6 +362,7 @@ void DisposeIsolateWhenPossible(Isolate* isolate) { PromiseProxy::Init(context); Events::Init(context); ErrorEvents::Init(context); + Performance::Init(context); Console::Init(context); WeakRef::Init(context); @@ -512,23 +513,8 @@ void DisposeIsolateWhenPossible(Isolate* isolate) { tns::Assert(success, isolate); } -void Runtime::DefinePerformanceObject(Isolate* isolate, Local globalTemplate) { - Local performanceTemplate = ObjectTemplate::New(isolate); - - Local nowFuncTemplate = FunctionTemplate::New(isolate, PerformanceNowCallback); - performanceTemplate->Set(tns::ToV8String(isolate, "now"), nowFuncTemplate); - - performanceTemplate->Set(tns::ToV8String(isolate, "timeOrigin"), - v8::Number::New(isolate, realtimeOrigin)); - - Local performancePropertyName = ToV8String(isolate, "performance"); - globalTemplate->Set(performancePropertyName, performanceTemplate); -} - -void Runtime::PerformanceNowCallback(const FunctionCallbackInfo& args) { - auto runtime = Runtime::GetRuntime(args.GetIsolate()); - args.GetReturnValue().Set( - (runtime->platform_->MonotonicallyIncreasingTime() - runtime->startTime) * 1000.0); +double Runtime::PerformanceNowMillis() { + return (platform_->MonotonicallyIncreasingTime() - timeOriginMonotonic_) * 1000.0; } void Runtime::DefineNativeScriptVersion(Isolate* isolate, Local globalTemplate) { diff --git a/NativeScript/runtime/js/performance.js b/NativeScript/runtime/js/performance.js new file mode 100644 index 00000000..f83b0de7 --- /dev/null +++ b/NativeScript/runtime/js/performance.js @@ -0,0 +1,577 @@ +"use strict"; +// High Resolution Time + User Timing Level 3 + Performance Timeline: the +// `performance` global and its interface objects. The native side contributes +// exactly two things through the binding bag — `now()` (double ms since this +// isolate's time origin, monotonic) and `timeOrigin` (wall-clock ms since the +// Unix epoch, sampled when the isolate's time origin was taken) — everything +// else is portable JS, intended to run unchanged on the Android runtime +// against an equivalent bag. +// +// Deliberate deviations from the specs: +// - mark/measure `detail` is held by reference (this runtime has no +// structuredClone), so entries retain whatever the caller passed until +// clearMarks()/clearMeasures(); the user-timing buffers are unbounded. +// - Observer callbacks are delivered from a microtask rather than a queued +// task. Delivery is still asynchronous relative to mark()/measure(), but it +// precedes timer callbacks scheduled in the same turn. +// - Failures the specs express as DOMException (SyntaxError, +// InvalidModificationError) are Error instances with `name` patched — +// DOMException does not exist in this runtime. +const { now, timeOrigin } = binding; +const { + ArrayPrototypeIndexOf, + ArrayPrototypePush, + ArrayPrototypeSlice, + ArrayPrototypeSort, + ArrayPrototypeSplice, + Error, + FunctionPrototypeCall, + Number, + NumberIsFinite, + ObjectDefineProperty, + ObjectFreeze, + ObjectGetOwnPropertyDescriptor, + String, + SymbolToStringTag, + TypeError, +} = primordials; +var g = globalThis; +// Init order (Runtime::Init) guarantees events.js and error-events.js ran +// first; captured before user code can replace them. +const EventTarget = g.EventTarget; +const enqueueMicrotask = g.queueMicrotask; +const reportException = g.reportError; + +// Construction token: interfaces whose constructors the spec marks as not +// user-invocable accept instances only from factories inside this module. +const kInternal = {}; + +function illegalConstructor() { + return new TypeError("Illegal constructor"); +} + +// SyntaxError / InvalidModificationError stand-in (see header). +function domException(message, name) { + const e = new Error(message); + e.name = name; + return e; +} + +class PerformanceEntry { + #name; + #entryType; + #startTime; + #duration; + constructor(token, name, entryType, startTime, duration) { + if (token !== kInternal) { + throw illegalConstructor(); + } + this.#name = name; + this.#entryType = entryType; + this.#startTime = startTime; + this.#duration = duration; + } + get name() { + return this.#name; + } + get entryType() { + return this.#entryType; + } + get startTime() { + return this.#startTime; + } + get duration() { + return this.#duration; + } + toJSON() { + return { + name: this.#name, + entryType: this.#entryType, + startTime: this.#startTime, + duration: this.#duration, + }; + } +} + +class PerformanceMark extends PerformanceEntry { + #detail; + constructor(markName, markOptions) { + if (arguments.length < 1) { + throw new TypeError("PerformanceMark: 1 argument required, but only 0 present"); + } + const name = String(markName); + let startTime; + let detail = null; + if (markOptions !== undefined && markOptions !== null) { + if (typeof markOptions !== "object" && typeof markOptions !== "function") { + throw new TypeError("PerformanceMark: options must be an object"); + } + if (markOptions.startTime !== undefined) { + startTime = Number(markOptions.startTime); + if (!NumberIsFinite(startTime)) { + throw new TypeError("PerformanceMark: startTime must be a finite number"); + } + if (startTime < 0) { + throw new TypeError("PerformanceMark: startTime cannot be negative"); + } + } + if (markOptions.detail !== undefined && markOptions.detail !== null) { + detail = markOptions.detail; + } + } + super(kInternal, name, "mark", startTime === undefined ? now() : startTime, 0); + this.#detail = detail; + } + get detail() { + return this.#detail; + } + toJSON() { + const json = super.toJSON(); + json.detail = this.#detail; + return json; + } +} + +class PerformanceMeasure extends PerformanceEntry { + #detail; + constructor(token, name, startTime, duration, detail) { + if (token !== kInternal) { + throw illegalConstructor(); + } + super(token, name, "measure", startTime, duration); + this.#detail = detail; + } + get detail() { + return this.#detail; + } + toJSON() { + const json = super.toJSON(); + json.detail = this.#detail; + return json; + } +} + +// ---- Performance timeline (per-isolate; this module runs once per isolate). + +const entries = []; // marks + measures, insertion order + +// Spec ordering for every query result: chronological by startTime; V8's +// stable sort preserves insertion order for ties. A copy is always returned — +// a measure may be inserted with a startTime that precedes buffered marks. +function chronological(list) { + const copy = ArrayPrototypeSlice(list); + ArrayPrototypeSort(copy, function (a, b) { + return a.startTime - b.startTime; + }); + return copy; +} + +function queryEntries(name, type) { + const result = []; + for (let i = 0; i < entries.length; i++) { + const e = entries[i]; + if (type !== undefined && e.entryType !== type) { + continue; + } + if (name !== undefined && e.name !== name) { + continue; + } + ArrayPrototypePush(result, e); + } + return chronological(result); +} + +function clearEntries(type, name) { + for (let i = entries.length - 1; i >= 0; i--) { + const e = entries[i]; + if (e.entryType !== type) { + continue; + } + if (name !== undefined && e.name !== name) { + continue; + } + ArrayPrototypeSplice(entries, i, 1); + } +} + +function bufferEntry(entry) { + ArrayPrototypePush(entries, entry); + notifyObservers(entry); +} + +// User Timing "convert a mark to a timestamp": numbers are timestamps +// (negative is a TypeError), everything else names the most recent mark. +function convertMarkToTimestamp(value) { + if (typeof value === "number") { + if (!NumberIsFinite(value)) { + throw new TypeError("Given timestamp must be a finite number"); + } + if (value < 0) { + throw new TypeError("Given timestamp cannot be negative"); + } + return value; + } + const name = String(value); + for (let i = entries.length - 1; i >= 0; i--) { + const e = entries[i]; + if (e.entryType === "mark" && e.name === name) { + return e.startTime; + } + } + throw domException("The mark '" + name + "' does not exist", "SyntaxError"); +} + +// ---- Performance observers. + +const SUPPORTED_ENTRY_TYPES = ObjectFreeze(["mark", "measure"]); + +// Registration order is the spec's delivery order. Each element is an +// observer's internal record ({ observer, callback, types, queue, mode }), +// reachable only from its PerformanceObserver instance and this list. +const observers = []; +let flushScheduled = false; + +function scheduleFlush() { + if (flushScheduled) { + return; + } + flushScheduled = true; + enqueueMicrotask(flushObservers); +} + +function flushObservers() { + flushScheduled = false; + const snapshot = ArrayPrototypeSlice(observers); + for (let i = 0; i < snapshot.length; i++) { + const record = snapshot[i]; + if (record.queue.length === 0) { + continue; + } + const taken = chronological(record.queue); + record.queue = []; + const list = new PerformanceObserverEntryList(kInternal, taken); + try { + FunctionPrototypeCall(record.callback, record.observer, list, record.observer); + } catch (e) { + // Spec: report the exception; one throwing observer must not starve + // the observers behind it. + reportException(e); + } + } +} + +function notifyObservers(entry) { + const type = entry.entryType; + let queued = false; + for (let i = 0; i < observers.length; i++) { + const record = observers[i]; + if (ArrayPrototypeIndexOf(record.types, type) === -1) { + continue; + } + ArrayPrototypePush(record.queue, entry); + queued = true; + } + if (queued) { + scheduleFlush(); + } +} + +class PerformanceObserverEntryList { + #entries; + constructor(token, list) { + if (token !== kInternal) { + throw illegalConstructor(); + } + this.#entries = list; + } + getEntries() { + return ArrayPrototypeSlice(this.#entries); + } + getEntriesByType(type) { + if (arguments.length < 1) { + throw new TypeError("getEntriesByType: 1 argument required, but only 0 present"); + } + return filterList(this.#entries, undefined, String(type)); + } + getEntriesByName(name, type) { + if (arguments.length < 1) { + throw new TypeError("getEntriesByName: 1 argument required, but only 0 present"); + } + return filterList(this.#entries, String(name), type === undefined ? undefined : String(type)); + } +} + +function filterList(list, name, type) { + const result = []; + for (let i = 0; i < list.length; i++) { + const e = list[i]; + if (type !== undefined && e.entryType !== type) { + continue; + } + if (name !== undefined && e.name !== name) { + continue; + } + ArrayPrototypePush(result, e); + } + return result; +} + +class PerformanceObserver { + #record; + constructor(callback) { + if (typeof callback !== "function") { + throw new TypeError("PerformanceObserver: the callback is not a function"); + } + this.#record = { + observer: this, + callback: callback, + types: [], + queue: [], + // Locked to "multiple" (entryTypes) or "single" (type) by the first + // observe() call, permanently — the spec forbids switching forms. + mode: null, + }; + } + observe(options) { + const record = this.#record; + if (options === null || typeof options !== "object") { + throw new TypeError("observe: options must be an object"); + } + const hasEntryTypes = options.entryTypes !== undefined; + const hasType = options.type !== undefined; + if (!hasEntryTypes && !hasType) { + throw new TypeError("observe: an observe() call must include either entryTypes or type"); + } + if (hasEntryTypes && (hasType || options.buffered !== undefined)) { + throw new TypeError("observe: entryTypes cannot be combined with type or buffered"); + } + const mode = hasEntryTypes ? "multiple" : "single"; + if (record.mode !== null && record.mode !== mode) { + throw domException( + "observe: this observer already used the " + + (record.mode === "multiple" ? "entryTypes" : "type") + + " form and cannot switch", + "InvalidModificationError" + ); + } + if (hasEntryTypes) { + const requested = options.entryTypes; + const supported = []; + for (let i = 0; i < requested.length; i++) { + const t = String(requested[i]); + if ( + ArrayPrototypeIndexOf(SUPPORTED_ENTRY_TYPES, t) !== -1 && + ArrayPrototypeIndexOf(supported, t) === -1 + ) { + ArrayPrototypePush(supported, t); + } + } + // Spec: no supported types → abort without throwing (and without + // registering). + if (supported.length === 0) { + return; + } + record.mode = mode; + record.types = supported; // the entryTypes form replaces the set + } else { + const t = String(options.type); + if (ArrayPrototypeIndexOf(SUPPORTED_ENTRY_TYPES, t) === -1) { + return; + } + record.mode = mode; + if (ArrayPrototypeIndexOf(record.types, t) === -1) { + ArrayPrototypePush(record.types, t); // the type form accumulates + } + if (options.buffered) { + for (let i = 0; i < entries.length; i++) { + if (entries[i].entryType === t) { + ArrayPrototypePush(record.queue, entries[i]); + } + } + if (record.queue.length !== 0) { + scheduleFlush(); + } + } + } + if (ArrayPrototypeIndexOf(observers, record) === -1) { + ArrayPrototypePush(observers, record); + } + } + disconnect() { + const record = this.#record; + const idx = ArrayPrototypeIndexOf(observers, record); + if (idx !== -1) { + ArrayPrototypeSplice(observers, idx, 1); + } + record.queue = []; // pending records are dropped; takeRecords() first to keep them + } + takeRecords() { + const record = this.#record; + const taken = chronological(record.queue); + record.queue = []; + return taken; + } + static get supportedEntryTypes() { + return SUPPORTED_ENTRY_TYPES; + } +} + +class Performance extends EventTarget { + constructor(token) { + if (token !== kInternal) { + throw illegalConstructor(); + } + super(); + // The EventTarget base installs `_listeners` as an own enumerable field; + // keep it out of Object.keys(performance)/JSON.stringify(performance). + ObjectDefineProperty(this, "_listeners", { + value: this._listeners, + writable: true, + enumerable: false, + configurable: true, + }); + } + get timeOrigin() { + return timeOrigin; + } + now() { + return now(); + } + toJSON() { + return { timeOrigin: timeOrigin }; + } + mark(markName, markOptions) { + if (arguments.length < 1) { + throw new TypeError("mark: 1 argument required, but only 0 present"); + } + const entry = new PerformanceMark(markName, markOptions); + bufferEntry(entry); + return entry; + } + measure(measureName, startOrMeasureOptions, endMark) { + if (arguments.length < 1) { + throw new TypeError("measure: 1 argument required, but only 0 present"); + } + const name = String(measureName); + const isOptionsObject = + startOrMeasureOptions !== null && typeof startOrMeasureOptions === "object"; + let startTime; + let endTime; + let detail = null; + if ( + isOptionsObject && + (startOrMeasureOptions.start !== undefined || + startOrMeasureOptions.end !== undefined || + startOrMeasureOptions.duration !== undefined || + startOrMeasureOptions.detail !== undefined) + ) { + const o = startOrMeasureOptions; + if (endMark !== undefined) { + throw new TypeError("measure: endMark cannot be combined with a measure options object"); + } + if (o.start === undefined && o.end === undefined) { + throw new TypeError("measure: the options object must specify start and/or end"); + } + if (o.start !== undefined && o.end !== undefined && o.duration !== undefined) { + throw new TypeError("measure: cannot specify start, end and duration together"); + } + let duration; + if (o.duration !== undefined) { + duration = Number(o.duration); + if (!NumberIsFinite(duration)) { + throw new TypeError("measure: duration must be a finite number"); + } + } + if (o.end !== undefined) { + endTime = convertMarkToTimestamp(o.end); + } else if (o.start !== undefined && duration !== undefined) { + endTime = convertMarkToTimestamp(o.start) + duration; + } else { + endTime = now(); + } + if (o.start !== undefined) { + startTime = convertMarkToTimestamp(o.start); + } else if (duration !== undefined) { + startTime = endTime - duration; + } else { + startTime = 0; + } + if (o.detail !== undefined && o.detail !== null) { + detail = o.detail; + } + } else { + endTime = endMark !== undefined ? convertMarkToTimestamp(endMark) : now(); + // A members-free options object means "no start given", not a mark name. + startTime = + startOrMeasureOptions !== undefined && !isOptionsObject + ? convertMarkToTimestamp(startOrMeasureOptions) + : 0; + } + const entry = new PerformanceMeasure(kInternal, name, startTime, endTime - startTime, detail); + bufferEntry(entry); + return entry; + } + clearMarks(markName) { + clearEntries("mark", markName === undefined ? undefined : String(markName)); + } + clearMeasures(measureName) { + clearEntries("measure", measureName === undefined ? undefined : String(measureName)); + } + getEntries() { + return chronological(entries); + } + getEntriesByType(type) { + if (arguments.length < 1) { + throw new TypeError("getEntriesByType: 1 argument required, but only 0 present"); + } + return queryEntries(undefined, String(type)); + } + getEntriesByName(name, type) { + if (arguments.length < 1) { + throw new TypeError("getEntriesByName: 1 argument required, but only 0 present"); + } + return queryEntries(String(name), type === undefined ? undefined : String(type)); + } +} + +// WebIDL shape: interface members are enumerable prototype properties and the +// class string is a configurable, non-writable Symbol.toStringTag; class +// syntax alone yields non-enumerable members. +function finishInterface(ctor, tag, members) { + const proto = ctor.prototype; + ObjectDefineProperty(proto, SymbolToStringTag, { + value: tag, + writable: false, + enumerable: false, + configurable: true, + }); + for (let i = 0; i < members.length; i++) { + const desc = ObjectGetOwnPropertyDescriptor(proto, members[i]); + desc.enumerable = true; + ObjectDefineProperty(proto, members[i], desc); + } +} +finishInterface(PerformanceEntry, "PerformanceEntry", [ + "name", "entryType", "startTime", "duration", "toJSON", +]); +finishInterface(PerformanceMark, "PerformanceMark", ["detail", "toJSON"]); +finishInterface(PerformanceMeasure, "PerformanceMeasure", ["detail", "toJSON"]); +finishInterface(PerformanceObserverEntryList, "PerformanceObserverEntryList", [ + "getEntries", "getEntriesByType", "getEntriesByName", +]); +finishInterface(PerformanceObserver, "PerformanceObserver", [ + "observe", "disconnect", "takeRecords", +]); +finishInterface(Performance, "Performance", [ + "timeOrigin", "now", "toJSON", "mark", "measure", + "clearMarks", "clearMeasures", + "getEntries", "getEntriesByType", "getEntriesByName", +]); + +g.Performance = Performance; +g.PerformanceEntry = PerformanceEntry; +g.PerformanceMark = PerformanceMark; +g.PerformanceMeasure = PerformanceMeasure; +g.PerformanceObserver = PerformanceObserver; +g.PerformanceObserverEntryList = PerformanceObserverEntryList; +g.performance = new Performance(kInternal); diff --git a/NativeScript/runtime/js/primordials.js b/NativeScript/runtime/js/primordials.js index 368d2a26..9c867d08 100644 --- a/NativeScript/runtime/js/primordials.js +++ b/NativeScript/runtime/js/primordials.js @@ -29,6 +29,7 @@ const intrinsics = { String, TypeError, SymbolHasInstance: Symbol.hasInstance, + SymbolToStringTag: Symbol.toStringTag, // Namespaces / prototypes. ObjectPrototype: Object.prototype, @@ -37,6 +38,7 @@ const intrinsics = { ArrayBufferIsView: ArrayBuffer.isView, ArrayIsArray: Array.isArray, JSONStringify: JSON.stringify, + NumberIsFinite: Number.isFinite, NumberParseFloat: Number.parseFloat, NumberParseInt: Number.parseInt, ObjectAssign: Object.assign, @@ -56,6 +58,7 @@ const intrinsics = { ArrayPrototypeIndexOf: uncurryThis(Array.prototype.indexOf), ArrayPrototypePush: uncurryThis(Array.prototype.push), ArrayPrototypeSlice: uncurryThis(Array.prototype.slice), + ArrayPrototypeSort: uncurryThis(Array.prototype.sort), ArrayPrototypeSplice: uncurryThis(Array.prototype.splice), FunctionPrototypeApply: uncurryThis(FunctionPrototypeApply), FunctionPrototypeBind: uncurryThis(FunctionPrototypeBind), diff --git a/TestRunner/app/shared b/TestRunner/app/shared index 3a262b97..4b88caea 160000 --- a/TestRunner/app/shared +++ b/TestRunner/app/shared @@ -1 +1 @@ -Subproject commit 3a262b979c6b84cdfe69cd495436a7088d016505 +Subproject commit 4b88caea63e8e5ee670d8be09c52a083ff79c165 diff --git a/TestRunner/app/tests/RuntimeImplementedAPIs.js b/TestRunner/app/tests/RuntimeImplementedAPIs.js index b3d581e2..db2df04b 100644 --- a/TestRunner/app/tests/RuntimeImplementedAPIs.js +++ b/TestRunner/app/tests/RuntimeImplementedAPIs.js @@ -17,34 +17,6 @@ describe("Runtime exposes", function () { }); }); -describe("Performance object", () => { - it("should be available", () => { - expect(performance).toBeDefined(); - }); - it("should have a now function", () => { - expect(performance.now).toBeDefined(); - }); - it("should have a now function that returns a number", () => { - expect(typeof performance.now()).toBe("number"); - }); - it("should have timeOrigin", () => { - expect(performance.timeOrigin).toBeDefined(); - }); - it("should have timeOrigin that is a number", () => { - expect(typeof performance.timeOrigin).toBe("number"); - }); - it("should have timeOrigin that is greater than 0", () => { - expect(performance.timeOrigin).toBeGreaterThan(0); - }); - it("should be close to the current time", () => { - const dateNow = Date.now(); - const performanceNow = performance.now(); - const timeOrigin = performance.timeOrigin; - const performanceAccurateNow = timeOrigin + performanceNow; - expect(Math.abs(dateNow - performanceAccurateNow)).toBeLessThan(10); - }); -}); - describe("queueMicrotask", () => { it("should be defined as a function", () => { expect(typeof queueMicrotask).toBe("function"); diff --git a/TestRunner/app/tests/index.js b/TestRunner/app/tests/index.js index 7fdd3869..a1663c1a 100644 --- a/TestRunner/app/tests/index.js +++ b/TestRunner/app/tests/index.js @@ -175,6 +175,9 @@ require("./ExtendedClassNamingTests"); // Tests common for all runtimes (git submodule of NativeScript/common-runtime-tests-app). require("../shared/index").runAllTests(); +// WHATWG performance (hr-time, user timing, performance timeline) — shared suite, iOS opt-in +require("../shared/index").runPerformanceTests(); + // (Optional) Custom testing for various optional sdk's and frameworks // These can be turned on manually to verify if needed anytime //require("./sdks/MusicKit"); diff --git a/docs/README.md b/docs/README.md index 19842bf8..315c85db 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,5 +1,9 @@ # Runtime documentation +- [Performance API](performance.md) — WHATWG `performance` (hr-time, user + timing, performance timeline with `PerformanceObserver`), per-isolate time + origins for workers, the native clock hook future `requestAnimationFrame` + work must share, and the documented spec deviations. - [Error handling](error-handling.md) — global `error`/`unhandledrejection` events, `reportError`, catching native exceptions in JS (`error.nativeException`), forwarding JS throws to native (`interop.escapeException`), JS stacks on `NSException`, configuration flags, and crash-reporter integration. ## Knowledge diff --git a/docs/performance.md b/docs/performance.md new file mode 100644 index 00000000..2f9141d4 --- /dev/null +++ b/docs/performance.md @@ -0,0 +1,75 @@ +# Performance API + +The runtime implements the WHATWG/WinterTC Performance surface: [High +Resolution Time](https://w3c.github.io/hr-time/), [User Timing Level +3](https://w3c.github.io/user-timing/) and the [Performance +Timeline](https://w3c.github.io/performance-timeline/) with +`PerformanceObserver`. + +## Surface + +Globals (own, writable, enumerable, configurable properties of `globalThis`, +in main and worker isolates alike): `performance`, `Performance`, +`PerformanceEntry`, `PerformanceMark`, `PerformanceMeasure`, +`PerformanceObserver`, `PerformanceObserverEntryList`. + +- `performance.now()` — double milliseconds since the isolate's time origin, + monotonic (V8 platform clock, `mach_absolute_time`-based: it does not tick + while the device is asleep), full double precision with no coarsening. +- `performance.timeOrigin` — readonly accessor; wall-clock milliseconds since + the Unix epoch, sampled once when the isolate's runtime is created. Each + worker gets its own time origin at worker-thread start, so + `timeOrigin + now()` tracks `Date.now()` per isolate. +- `performance.toJSON()`, `Symbol.toStringTag`, and `Performance extends + EventTarget` per spec; `performance`, `PerformanceEntry`, + `PerformanceMeasure` and `PerformanceObserverEntryList` are not + user-constructible (`new` throws `TypeError`); `new PerformanceMark(name, + options)` is constructible per spec but does not buffer the entry. +- User timing: `mark(name, {startTime, detail})`, `measure(name, + startOrOptions, endMark)` with the full Level 3 options algebra (`{start, + end, duration, detail}`, mark names or timestamps, over-/under-constraint + errors), `clearMarks(name?)`, `clearMeasures(name?)`. +- Timeline: `getEntries()`, `getEntriesByType(type)`, `getEntriesByName(name, + type?)` return copies sorted chronologically by `startTime` (stable for + ties). +- Observers: `new PerformanceObserver(cb)`, `observe({entryTypes})` or + `observe({type, buffered})`, `disconnect()`, `takeRecords()`, static frozen + `PerformanceObserver.supportedEntryTypes === ["mark", "measure"]`. + +## Architecture + +All spec logic lives in the `internal/performance.js` builtin +(`NativeScript/runtime/js/performance.js`), which is deliberately portable: +the native side hands it only `{ now(), timeOrigin }`, so the same file is +meant to be reused unchanged by the Android runtime against an equivalent +binding bag. + +The native clock is owned by `Runtime` (`Runtime::PerformanceNowMillis()`, +`Runtime::TimeOriginMillis()`, captured in `Runtime::CreateIsolate`) and +exposed to native callers through `tns::Performance::NowMillis(isolate)` +(`NativeScript/runtime/Performance.h`). Any future native producer of +JS-visible timestamps — `requestAnimationFrame` in particular — must read the +clock through that hook rather than sampling its own, so every timestamp +shares `performance.timeOrigin` as its base. + +## Deviations from the specs + +- **`detail` is held by reference.** The runtime has no `structuredClone`, so + `mark`/`measure` `detail` values are stored as-is. Mutating the object later + is visible through the entry, and entries retain whatever `detail` + references until `clearMarks()`/`clearMeasures()`. +- **Buffers are unbounded.** Per spec for user timing, but combined with + by-reference `detail` it means a long-lived app marking in a loop should + clear entries periodically. +- **Observer callbacks run from a microtask**, not a queued task: delivery is + asynchronous relative to `mark()`/`measure()` but precedes timer callbacks + scheduled in the same turn. Callback exceptions are routed to + `reportError`, so one throwing observer does not starve the others. +- **No `DOMException`.** Errors the specs express as `DOMException` — the + `SyntaxError` for a missing mark name, the `InvalidModificationError` for + switching an observer between the `entryTypes` and `type` forms — are + `Error` instances with `name` patched. `err.name` checks work; + `instanceof DOMException` does not. +- Browser-only surface is absent: no resource/navigation timing, no + `eventCounts`, and no `PerformanceTiming`-attribute resolution in + `measure()`. diff --git a/eslint.config.mjs b/eslint.config.mjs index 5eb59bfa..08d25891 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -15,6 +15,7 @@ const capturedStatics = [ ['Array', 'isArray', 'ArrayIsArray'], ['ArrayBuffer', 'isView', 'ArrayBufferIsView'], ['JSON', 'stringify', 'JSONStringify'], + ['Number', 'isFinite', 'NumberIsFinite'], ['Number', 'parseFloat', 'NumberParseFloat'], ['Number', 'parseInt', 'NumberParseInt'], ['Object', 'assign', 'ObjectAssign'], diff --git a/tools/js2c-inputs.xcfilelist b/tools/js2c-inputs.xcfilelist index b76a9d8a..9acaedec 100644 --- a/tools/js2c-inputs.xcfilelist +++ b/tools/js2c-inputs.xcfilelist @@ -9,6 +9,7 @@ $(SRCROOT)/NativeScript/runtime/js/inspect.js $(SRCROOT)/NativeScript/runtime/js/node-util.js $(SRCROOT)/NativeScript/runtime/js/ns-runtime.js $(SRCROOT)/NativeScript/runtime/js/ns-util.js +$(SRCROOT)/NativeScript/runtime/js/performance.js $(SRCROOT)/NativeScript/runtime/js/promise-proxy.js $(SRCROOT)/NativeScript/runtime/js/require-factory.js $(SRCROOT)/NativeScript/runtime/js/ts-helpers.js diff --git a/v8ios.xcodeproj/project.pbxproj b/v8ios.xcodeproj/project.pbxproj index 67b56f0a..c3ad68a1 100644 --- a/v8ios.xcodeproj/project.pbxproj +++ b/v8ios.xcodeproj/project.pbxproj @@ -293,6 +293,7 @@ C2DDEB92229EAC8300345BFE /* WeakRef.cpp in Sources */ = {isa = PBXBuildFile; fileRef = C2DDEB69229EAC8100345BFE /* WeakRef.cpp */; }; 4A5C201A2E2B000100000006 /* BuiltinLoader.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 4A5C201A2E2B000100000001 /* BuiltinLoader.cpp */; }; 4A5C201A2E2B000100000007 /* RuntimeBuiltins.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 4A5C201A2E2B000100000003 /* RuntimeBuiltins.cpp */; }; + 4A5C201A2E2B000300000006 /* Performance.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 4A5C201A2E2B000300000001 /* Performance.cpp */; }; 4A5C201A2E2B000200000006 /* NsBuiltinModules.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 4A5C201A2E2B000200000001 /* NsBuiltinModules.cpp */; }; C2DDEB93229EAC8300345BFE /* ArgConverter.mm in Sources */ = {isa = PBXBuildFile; fileRef = C2DDEB6A229EAC8200345BFE /* ArgConverter.mm */; }; C2DDEB94229EAC8300345BFE /* Console.cpp in Sources */ = {isa = PBXBuildFile; fileRef = C2DDEB6B229EAC8200345BFE /* Console.cpp */; }; @@ -815,6 +816,8 @@ 4A5C201A2E2B000100000003 /* RuntimeBuiltins.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; name = RuntimeBuiltins.cpp; path = generated/RuntimeBuiltins.cpp; sourceTree = ""; }; 4A5C201A2E2B000100000004 /* RuntimeBuiltins.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = RuntimeBuiltins.h; path = generated/RuntimeBuiltins.h; sourceTree = ""; }; 4A5C201A2E2B000100000005 /* js */ = {isa = PBXFileReference; lastKnownFileType = folder; path = js; sourceTree = ""; }; + 4A5C201A2E2B000300000001 /* Performance.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; path = Performance.cpp; sourceTree = ""; }; + 4A5C201A2E2B000300000002 /* Performance.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = Performance.h; sourceTree = ""; }; C2DDEB6A229EAC8200345BFE /* ArgConverter.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; path = ArgConverter.mm; sourceTree = ""; }; C2DDEB6B229EAC8200345BFE /* Console.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; path = Console.cpp; sourceTree = ""; }; C2DDEB6C229EAC8200345BFE /* SetTimeout.cpp */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.cpp; path = SetTimeout.cpp; sourceTree = ""; }; @@ -1500,6 +1503,8 @@ C2DDEB86229EAC8300345BFE /* ObjectManager.mm */, C20AB5E526E1015200E2B41D /* OneByteStringResource.h */, C20AB5E426E1015200E2B41D /* OneByteStringResource.cpp */, + 4A5C201A2E2B000300000002 /* Performance.h */, + 4A5C201A2E2B000300000001 /* Performance.cpp */, C266569222AFFF7E00EE15CC /* Pointer.h */, C266569122AFFF7E00EE15CC /* Pointer.cpp */, C2D7E9D323F42C1100DB289C /* PromiseProxy.h */, @@ -2343,6 +2348,7 @@ C2D7E9D623F42C1100DB289C /* PromiseProxy.cpp in Sources */, C79DADCF4D076CD80EE4ED13 /* ErrorEvents.cpp in Sources */, 462FA976C64356112F69C395 /* Events.cpp in Sources */, + 4A5C201A2E2B000300000006 /* Performance.cpp in Sources */, 4C4DD7153616866C54B2CD47 /* NSExceptionSupport.mm in Sources */, 3CEA20DC2A7DA8320009BE8F /* IsolateWrapper.cpp in Sources */, C275F477253B37AB00A997D5 /* UnmanagedType.mm in Sources */, From 3f2aa651f38991d4a52a237ded6a0c05fcbea8d8 Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 10 Aug 2026 19:00:10 -0300 Subject: [PATCH 2/4] fix(performance): convert observe() entryTypes per WebIDL sequence semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A non-iterable entryTypes (a number, an array-like with no @@iterator) and string primitives now throw TypeError instead of silently observing nothing, and any iterable — a Set, not just an Array — converts. Adds the SymbolIterator primordial. Also qualifies the timeOrigin + now() vs Date.now() relationship in the docs (device sleep, wall-clock adjustments) and bumps the shared suite with a spec pinning the conversion. --- NativeScript/runtime/js/performance.js | 23 +++++++++++++++++++++-- NativeScript/runtime/js/primordials.js | 1 + TestRunner/app/shared | 2 +- docs/README.md | 2 +- docs/performance.md | 6 ++++-- 5 files changed, 28 insertions(+), 6 deletions(-) diff --git a/NativeScript/runtime/js/performance.js b/NativeScript/runtime/js/performance.js index f83b0de7..6a4ef97b 100644 --- a/NativeScript/runtime/js/performance.js +++ b/NativeScript/runtime/js/performance.js @@ -32,6 +32,7 @@ const { ObjectFreeze, ObjectGetOwnPropertyDescriptor, String, + SymbolIterator, SymbolToStringTag, TypeError, } = primordials; @@ -57,6 +58,24 @@ function domException(message, name) { return e; } +// WebIDL sequence conversion: only an object with a callable +// @@iterator converts (so a Set works and a string primitive does not); +// anything else is a TypeError, never a silent no-op. +function convertStringSequence(value, context) { + if ( + value === null || + (typeof value !== "object" && typeof value !== "function") || + typeof value[SymbolIterator] !== "function" + ) { + throw new TypeError(context + " is not iterable"); + } + const result = []; + for (const item of value) { + ArrayPrototypePush(result, String(item)); + } + return result; +} + class PerformanceEntry { #name; #entryType; @@ -355,10 +374,10 @@ class PerformanceObserver { ); } if (hasEntryTypes) { - const requested = options.entryTypes; + const requested = convertStringSequence(options.entryTypes, "entryTypes"); const supported = []; for (let i = 0; i < requested.length; i++) { - const t = String(requested[i]); + const t = requested[i]; if ( ArrayPrototypeIndexOf(SUPPORTED_ENTRY_TYPES, t) !== -1 && ArrayPrototypeIndexOf(supported, t) === -1 diff --git a/NativeScript/runtime/js/primordials.js b/NativeScript/runtime/js/primordials.js index 9c867d08..a8afb485 100644 --- a/NativeScript/runtime/js/primordials.js +++ b/NativeScript/runtime/js/primordials.js @@ -29,6 +29,7 @@ const intrinsics = { String, TypeError, SymbolHasInstance: Symbol.hasInstance, + SymbolIterator: Symbol.iterator, SymbolToStringTag: Symbol.toStringTag, // Namespaces / prototypes. diff --git a/TestRunner/app/shared b/TestRunner/app/shared index 4b88caea..cbac0f0f 160000 --- a/TestRunner/app/shared +++ b/TestRunner/app/shared @@ -1 +1 @@ -Subproject commit 4b88caea63e8e5ee670d8be09c52a083ff79c165 +Subproject commit cbac0f0f1f3142012085370483b652657ab9ed2f diff --git a/docs/README.md b/docs/README.md index 315c85db..3ed370a3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,7 +2,7 @@ - [Performance API](performance.md) — WHATWG `performance` (hr-time, user timing, performance timeline with `PerformanceObserver`), per-isolate time - origins for workers, the native clock hook future `requestAnimationFrame` + origins for workers, the native clock hook that future `requestAnimationFrame` work must share, and the documented spec deviations. - [Error handling](error-handling.md) — global `error`/`unhandledrejection` events, `reportError`, catching native exceptions in JS (`error.nativeException`), forwarding JS throws to native (`interop.escapeException`), JS stacks on `NSException`, configuration flags, and crash-reporter integration. diff --git a/docs/performance.md b/docs/performance.md index 2f9141d4..a74c2f31 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -1,6 +1,6 @@ # Performance API -The runtime implements the WHATWG/WinterTC Performance surface: [High +The runtime implements the WHATWG/WinterTC-standard Performance surface: [High Resolution Time](https://w3c.github.io/hr-time/), [User Timing Level 3](https://w3c.github.io/user-timing/) and the [Performance Timeline](https://w3c.github.io/performance-timeline/) with @@ -19,7 +19,9 @@ in main and worker isolates alike): `performance`, `Performance`, - `performance.timeOrigin` — readonly accessor; wall-clock milliseconds since the Unix epoch, sampled once when the isolate's runtime is created. Each worker gets its own time origin at worker-thread start, so - `timeOrigin + now()` tracks `Date.now()` per isolate. + `timeOrigin + now()` approximates `Date.now()` per isolate while the device + stays awake; it drifts behind after device sleep (the monotonic clock does + not tick then) and diverges under wall-clock adjustments. - `performance.toJSON()`, `Symbol.toStringTag`, and `Performance extends EventTarget` per spec; `performance`, `PerformanceEntry`, `PerformanceMeasure` and `PerformanceObserverEntryList` are not From 20276c5513103dc3302f86c0cdc07e7430a0ad7e Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 10 Aug 2026 20:26:20 -0300 Subject: [PATCH 3/4] test: run the shared Performance suite via runAllTests with an unguarded canary The shared suite now gates itself on the API being present, so the explicit opt-in call is gone and the suite runs through runAllTests() on every runtime. A canary in RuntimeImplementedAPIs.js asserts the globals exist on this runtime, so the shared gate cannot silently skip a regression here. --- TestRunner/app/shared | 2 +- TestRunner/app/tests/RuntimeImplementedAPIs.js | 10 ++++++++++ TestRunner/app/tests/index.js | 3 --- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/TestRunner/app/shared b/TestRunner/app/shared index cbac0f0f..c854d767 160000 --- a/TestRunner/app/shared +++ b/TestRunner/app/shared @@ -1 +1 @@ -Subproject commit cbac0f0f1f3142012085370483b652657ab9ed2f +Subproject commit c854d76715a7d57cececfc42283346ee4150cbe6 diff --git a/TestRunner/app/tests/RuntimeImplementedAPIs.js b/TestRunner/app/tests/RuntimeImplementedAPIs.js index db2df04b..ea87ea2c 100644 --- a/TestRunner/app/tests/RuntimeImplementedAPIs.js +++ b/TestRunner/app/tests/RuntimeImplementedAPIs.js @@ -17,6 +17,16 @@ describe("Runtime exposes", function () { }); }); +// The shared Performance suite (submodule) gates itself on the API being +// present and skips otherwise; this unguarded canary makes absence on THIS +// runtime a failure rather than a silent skip. +describe("Performance API canary", () => { + it("implements the Performance API", () => { + expect(typeof performance.mark).toBe("function"); + expect(typeof PerformanceObserver).toBe("function"); + }); +}); + describe("queueMicrotask", () => { it("should be defined as a function", () => { expect(typeof queueMicrotask).toBe("function"); diff --git a/TestRunner/app/tests/index.js b/TestRunner/app/tests/index.js index a1663c1a..7fdd3869 100644 --- a/TestRunner/app/tests/index.js +++ b/TestRunner/app/tests/index.js @@ -175,9 +175,6 @@ require("./ExtendedClassNamingTests"); // Tests common for all runtimes (git submodule of NativeScript/common-runtime-tests-app). require("../shared/index").runAllTests(); -// WHATWG performance (hr-time, user timing, performance timeline) — shared suite, iOS opt-in -require("../shared/index").runPerformanceTests(); - // (Optional) Custom testing for various optional sdk's and frameworks // These can be turned on manually to verify if needed anytime //require("./sdks/MusicKit"); From a1e1976335027b82a68189eeac1f3e6fa6a0b77b Mon Sep 17 00:00:00 2001 From: Eduardo Speroni Date: Mon, 10 Aug 2026 23:04:49 -0300 Subject: [PATCH 4/4] chore: point shared tests at master with the merged suites Both shared suites (#25, #26) are on common-runtime-tests-app master now; the structuredClone suite exercises its skip gate on this branch (one pending spec), since this branch does not implement that API. --- TestRunner/app/shared | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TestRunner/app/shared b/TestRunner/app/shared index c854d767..2eee85b4 160000 --- a/TestRunner/app/shared +++ b/TestRunner/app/shared @@ -1 +1 @@ -Subproject commit c854d76715a7d57cececfc42283346ee4150cbe6 +Subproject commit 2eee85b4ad4863b59bc22a356246d2cbe5cb62c4