From 565608b42a256903bf5ab71d23fa65f68932f1a0 Mon Sep 17 00:00:00 2001 From: Adin Date: Tue, 3 Mar 2026 14:01:08 +0700 Subject: [PATCH 1/5] feat: add JSTracing hook for tracing property get and set --- .../FundamentalObjects/JSObject.swift | 32 +++++++++++++++++++ Sources/JavaScriptKit/JSTracing.swift | 2 ++ 2 files changed, 34 insertions(+) diff --git a/Sources/JavaScriptKit/FundamentalObjects/JSObject.swift b/Sources/JavaScriptKit/FundamentalObjects/JSObject.swift index caacd49f2..d5895c17f 100644 --- a/Sources/JavaScriptKit/FundamentalObjects/JSObject.swift +++ b/Sources/JavaScriptKit/FundamentalObjects/JSObject.swift @@ -153,10 +153,18 @@ public class JSObject: Equatable, ExpressibleByDictionaryLiteral { public subscript(_ name: String) -> JSValue { get { assertOnOwnerThread(hint: "reading '\(name)' property") + #if Tracing + let traceEnd = JSTracingHooks.beginJSCall(.propertyGet(receiver: self, propertyName: name)) + defer { traceEnd?() } + #endif return getJSValue(this: self, name: JSString(name)) } set { assertOnOwnerThread(hint: "writing '\(name)' property") + #if Tracing + let traceEnd = JSTracingHooks.beginJSCall(.propertySet(receiver: self, propertyName: name, value: newValue)) + defer { traceEnd?() } + #endif setJSValue(this: self, name: JSString(name), value: newValue) } } @@ -167,10 +175,18 @@ public class JSObject: Equatable, ExpressibleByDictionaryLiteral { public subscript(_ name: JSString) -> JSValue { get { assertOnOwnerThread(hint: "reading '<>' property") + #if Tracing + let traceEnd = JSTracingHooks.beginJSCall(.propertyGet(receiver: self, propertyName: String(name))) + defer { traceEnd?() } + #endif return getJSValue(this: self, name: name) } set { assertOnOwnerThread(hint: "writing '<>' property") + #if Tracing + let traceEnd = JSTracingHooks.beginJSCall(.propertySet(receiver: self, propertyName: String(name), value: newValue)) + defer { traceEnd?() } + #endif setJSValue(this: self, name: name, value: newValue) } } @@ -181,10 +197,18 @@ public class JSObject: Equatable, ExpressibleByDictionaryLiteral { public subscript(_ index: Int) -> JSValue { get { assertOnOwnerThread(hint: "reading '\(index)' property") + #if Tracing + let traceEnd = JSTracingHooks.beginJSCall(.propertyGet(receiver: self, propertyName: String(index))) + defer { traceEnd?() } + #endif return getJSValue(this: self, index: Int32(index)) } set { assertOnOwnerThread(hint: "writing '\(index)' property") + #if Tracing + let traceEnd = JSTracingHooks.beginJSCall(.propertySet(receiver: self, propertyName: String(index), value: newValue)) + defer { traceEnd?() } + #endif setJSValue(this: self, index: Int32(index), value: newValue) } } @@ -195,10 +219,18 @@ public class JSObject: Equatable, ExpressibleByDictionaryLiteral { public subscript(_ name: JSSymbol) -> JSValue { get { assertOnOwnerThread(hint: "reading '<>' property") + #if Tracing + let traceEnd = JSTracingHooks.beginJSCall(.propertyGet(receiver: self, propertyName: "<>")) + defer { traceEnd?() } + #endif return getJSValue(this: self, symbol: name) } set { assertOnOwnerThread(hint: "writing '<>' property") + #if Tracing + let traceEnd = JSTracingHooks.beginJSCall(.propertySet(receiver: self, propertyName: "<>", value: newValue)) + defer { traceEnd?() } + #endif setJSValue(this: self, symbol: name, value: newValue) } } diff --git a/Sources/JavaScriptKit/JSTracing.swift b/Sources/JavaScriptKit/JSTracing.swift index 8804e9afb..28e2a1bf8 100644 --- a/Sources/JavaScriptKit/JSTracing.swift +++ b/Sources/JavaScriptKit/JSTracing.swift @@ -7,6 +7,8 @@ public struct JSTracing: Sendable { public enum JSCallInfo { case function(function: JSObject, arguments: [JSValue]) case method(receiver: JSObject, methodName: String?, arguments: [JSValue]) + case propertyGet(receiver: JSObject, propertyName: String) + case propertySet(receiver: JSObject, propertyName: String, value: JSValue) } /// Register a hook for Swift to JavaScript calls. From a6a53a9b01960db687de72fd9b8188077fe40775 Mon Sep 17 00:00:00 2001 From: Adin Date: Tue, 3 Mar 2026 14:01:37 +0700 Subject: [PATCH 2/5] chore: add unit test for reporting JSTracing prop access --- Tests/JavaScriptKitTests/JSTracingTests.swift | 57 ++++++++++++++++++- 1 file changed, 54 insertions(+), 3 deletions(-) diff --git a/Tests/JavaScriptKitTests/JSTracingTests.swift b/Tests/JavaScriptKitTests/JSTracingTests.swift index 84fb9bfc6..90c547762 100644 --- a/Tests/JavaScriptKitTests/JSTracingTests.swift +++ b/Tests/JavaScriptKitTests/JSTracingTests.swift @@ -16,15 +16,66 @@ final class JSTracingTests: XCTestCase { let prop5 = try XCTUnwrap(globalObject1.prop_5.object) _ = prop5.func6!(true, 1, 2) - XCTAssertEqual(startInfo.count, 1) - guard case let .method(receiver, methodName, arguments) = startInfo.first else { + let methodEvents = startInfo.filter { + if case .method = $0 { return true } + return false + } + XCTAssertEqual(methodEvents.count, 1) + guard case let .method(receiver, methodName, arguments) = methodEvents.first else { XCTFail("Expected method info") return } XCTAssertEqual(receiver.id, prop5.id) XCTAssertEqual(methodName, "func6") XCTAssertEqual(arguments, [.boolean(true), .number(1), .number(2)]) - XCTAssertEqual(ended, 1) + XCTAssertEqual(ended, startInfo.count) + } + + func testJSCallHookReportsPropertyAccess() throws { + var startInfo: [JSTracing.JSCallInfo] = [] + var ended = 0 + let remove = JSTracing.default.addJSCallHook { info in + startInfo.append(info) + return { ended += 1 } + } + defer { remove() } + + let obj = JSObject.global.globalObject1 + + // Read a property (triggers propertyGet) + let _ = obj.prop_1 + + // Write a property (triggers propertySet) + obj.prop_1 = .number(999) + + // Filter to only propertyGet/propertySet events (subscript reads for the + // method-call test fixture also fire propertyGet, so be precise). + let propEvents = startInfo.filter { + switch $0 { + case .propertyGet(_, let name) where name == "prop_1": return true + case .propertySet(_, let name, _) where name == "prop_1": return true + default: return false + } + } + + XCTAssertEqual(propEvents.count, 2) + + guard case .propertyGet(let getReceiver, let getName) = propEvents[0] else { + XCTFail("Expected propertyGet info") + return + } + XCTAssertEqual(getReceiver.id, obj.object!.id) + XCTAssertEqual(getName, "prop_1") + + guard case .propertySet(let setReceiver, let setName, let setValue) = propEvents[1] else { + XCTFail("Expected propertySet info") + return + } + XCTAssertEqual(setReceiver.id, obj.object!.id) + XCTAssertEqual(setName, "prop_1") + XCTAssertEqual(setValue, .number(999)) + + XCTAssertEqual(ended, startInfo.count) } func testJSClosureCallHookReportsMetadata() throws { From 6d1a47076113b5e528fb6e23fdca92ac02519584 Mon Sep 17 00:00:00 2001 From: Adin Date: Tue, 3 Mar 2026 14:16:06 +0700 Subject: [PATCH 3/5] chore: format --- .../JavaScriptKit/FundamentalObjects/JSObject.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Sources/JavaScriptKit/FundamentalObjects/JSObject.swift b/Sources/JavaScriptKit/FundamentalObjects/JSObject.swift index d5895c17f..1b6facada 100644 --- a/Sources/JavaScriptKit/FundamentalObjects/JSObject.swift +++ b/Sources/JavaScriptKit/FundamentalObjects/JSObject.swift @@ -184,7 +184,9 @@ public class JSObject: Equatable, ExpressibleByDictionaryLiteral { set { assertOnOwnerThread(hint: "writing '<>' property") #if Tracing - let traceEnd = JSTracingHooks.beginJSCall(.propertySet(receiver: self, propertyName: String(name), value: newValue)) + let traceEnd = JSTracingHooks.beginJSCall( + .propertySet(receiver: self, propertyName: String(name), value: newValue) + ) defer { traceEnd?() } #endif setJSValue(this: self, name: name, value: newValue) @@ -206,7 +208,9 @@ public class JSObject: Equatable, ExpressibleByDictionaryLiteral { set { assertOnOwnerThread(hint: "writing '\(index)' property") #if Tracing - let traceEnd = JSTracingHooks.beginJSCall(.propertySet(receiver: self, propertyName: String(index), value: newValue)) + let traceEnd = JSTracingHooks.beginJSCall( + .propertySet(receiver: self, propertyName: String(index), value: newValue) + ) defer { traceEnd?() } #endif setJSValue(this: self, index: Int32(index), value: newValue) @@ -228,7 +232,9 @@ public class JSObject: Equatable, ExpressibleByDictionaryLiteral { set { assertOnOwnerThread(hint: "writing '<>' property") #if Tracing - let traceEnd = JSTracingHooks.beginJSCall(.propertySet(receiver: self, propertyName: "<>", value: newValue)) + let traceEnd = JSTracingHooks.beginJSCall( + .propertySet(receiver: self, propertyName: "<>", value: newValue) + ) defer { traceEnd?() } #endif setJSValue(this: self, symbol: name, value: newValue) From 1eef3519d38e76ceb51e98066d0b9ff192eb3702 Mon Sep 17 00:00:00 2001 From: Adin Date: Tue, 3 Mar 2026 14:29:14 +0700 Subject: [PATCH 4/5] chore: fix unit test by disambiguating type --- Tests/JavaScriptKitTests/JSTracingTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/JavaScriptKitTests/JSTracingTests.swift b/Tests/JavaScriptKitTests/JSTracingTests.swift index 90c547762..3b404606c 100644 --- a/Tests/JavaScriptKitTests/JSTracingTests.swift +++ b/Tests/JavaScriptKitTests/JSTracingTests.swift @@ -43,7 +43,7 @@ final class JSTracingTests: XCTestCase { let obj = JSObject.global.globalObject1 // Read a property (triggers propertyGet) - let _ = obj.prop_1 + let _: JSValue = obj.prop_1 // Write a property (triggers propertySet) obj.prop_1 = .number(999) From ded98aaf07d0e64890d3d05110dacde4c48af5f2 Mon Sep 17 00:00:00 2001 From: Adin Date: Tue, 3 Mar 2026 15:10:00 +0700 Subject: [PATCH 5/5] chore: create new JSObject instead of using globals in tracing unittest --- Tests/JavaScriptKitTests/JSTracingTests.swift | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/Tests/JavaScriptKitTests/JSTracingTests.swift b/Tests/JavaScriptKitTests/JSTracingTests.swift index 3b404606c..755e89d49 100644 --- a/Tests/JavaScriptKitTests/JSTracingTests.swift +++ b/Tests/JavaScriptKitTests/JSTracingTests.swift @@ -40,20 +40,23 @@ final class JSTracingTests: XCTestCase { } defer { remove() } - let obj = JSObject.global.globalObject1 + let obj = JSObject() + obj.foo = .number(42) + + // Reset after setup so we only capture the reads/writes below. + startInfo.removeAll() + ended = 0 // Read a property (triggers propertyGet) - let _: JSValue = obj.prop_1 + let _: JSValue = obj.foo // Write a property (triggers propertySet) - obj.prop_1 = .number(999) + obj.foo = .number(999) - // Filter to only propertyGet/propertySet events (subscript reads for the - // method-call test fixture also fire propertyGet, so be precise). let propEvents = startInfo.filter { switch $0 { - case .propertyGet(_, let name) where name == "prop_1": return true - case .propertySet(_, let name, _) where name == "prop_1": return true + case .propertyGet(_, let name) where name == "foo": return true + case .propertySet(_, let name, _) where name == "foo": return true default: return false } } @@ -64,15 +67,15 @@ final class JSTracingTests: XCTestCase { XCTFail("Expected propertyGet info") return } - XCTAssertEqual(getReceiver.id, obj.object!.id) - XCTAssertEqual(getName, "prop_1") + XCTAssertEqual(getReceiver.id, obj.id) + XCTAssertEqual(getName, "foo") guard case .propertySet(let setReceiver, let setName, let setValue) = propEvents[1] else { XCTFail("Expected propertySet info") return } - XCTAssertEqual(setReceiver.id, obj.object!.id) - XCTAssertEqual(setName, "prop_1") + XCTAssertEqual(setReceiver.id, obj.id) + XCTAssertEqual(setName, "foo") XCTAssertEqual(setValue, .number(999)) XCTAssertEqual(ended, startInfo.count)