From 3fbf322ea8b333c0aeb810824d2bdd37f0ee7b04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Str=C3=B8iman?= Date: Mon, 2 Feb 2026 18:07:00 +0100 Subject: [PATCH 01/12] Implement Array type with push --- scripting/internal/js/array.go | 6 +++++ scripting/internal/js/callback_context.go | 2 +- scripting/sobekengine/array.go | 31 +++++++++++++++++++++++ scripting/sobekengine/scope.go | 4 +-- scripting/sobekengine/value.go | 1 + scripting/v8engine/array.go | 25 ++++++++++++++++++ scripting/v8engine/callback_context.go | 8 ++++-- scripting/v8engine/value.go | 1 + 8 files changed, 73 insertions(+), 5 deletions(-) create mode 100644 scripting/internal/js/array.go create mode 100644 scripting/sobekengine/array.go create mode 100644 scripting/v8engine/array.go diff --git a/scripting/internal/js/array.go b/scripting/internal/js/array.go new file mode 100644 index 000000000..a855f983d --- /dev/null +++ b/scripting/internal/js/array.go @@ -0,0 +1,6 @@ +package js + +type Array[T any] interface { + Value[T] + Push(Value[T]) error +} diff --git a/scripting/internal/js/callback_context.go b/scripting/internal/js/callback_context.go index baf140fbb..fe498fc06 100644 --- a/scripting/internal/js/callback_context.go +++ b/scripting/internal/js/callback_context.go @@ -179,7 +179,7 @@ type ValueFactory[T any] interface { // NewArray creates a JavaScript array containing the values. If any value // is nil, it will become undefined in the resulting array. - NewArray(...Value[T]) Value[T] + NewArray(...Value[T]) Array[T] // NewIterator returns an object implementing the [Iterator protocol] // // [Iterator protocol]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Iteration_protocols#the_iterator_protocol diff --git a/scripting/sobekengine/array.go b/scripting/sobekengine/array.go new file mode 100644 index 000000000..4a2495470 --- /dev/null +++ b/scripting/sobekengine/array.go @@ -0,0 +1,31 @@ +package sobekengine + +import ( + "errors" + + "github.com/grafana/sobek" +) + +type array struct { + value + obj *sobek.Object +} + +func newArray(c *scriptContext, o *sobek.Object) jsArray { + return array{value{c, o}, o} +} + +func (a array) Push(v jsValue) error { + push := a.obj.Get("push") + if push == nil { + return errors.New("gost-dom/sobekengine: array.push: Underlying object doesn't have a push") + } + p, ok := sobek.AssertFunction(push) + if !ok { + return errors.New( + "gost-dom/sobekengine: array.push: Underlying object's push is not a function", + ) + } + _, err := p(a.obj, v.Self().value) + return err +} diff --git a/scripting/sobekengine/scope.go b/scripting/sobekengine/scope.go index e19c04d0e..d744c163c 100644 --- a/scripting/sobekengine/scope.go +++ b/scripting/sobekengine/scope.go @@ -64,12 +64,12 @@ func (f scope) JSONStringify(v js.Value[jsTypeParam]) string { panic(fmt.Sprintf("gost-dom/sobekhost: JSONStringify only supports objects. Got: %v", v)) } -func (f scope) NewArray(v ...js.Value[jsTypeParam]) js.Value[jsTypeParam] { +func (f scope) NewArray(v ...js.Value[jsTypeParam]) jsArray { arr := make([]any, len(v)) for i, val := range v { arr[i] = unwrapValue(val) } - return newObject(f.scriptContext, f.vm.NewArray(arr...)) + return newArray(f.scriptContext, f.vm.NewArray(arr...)) } func (f scope) NewBoolean(v bool) js.Value[jsTypeParam] { diff --git a/scripting/sobekengine/value.go b/scripting/sobekengine/value.go index 41752b2f9..880ce1370 100644 --- a/scripting/sobekengine/value.go +++ b/scripting/sobekengine/value.go @@ -8,6 +8,7 @@ import ( type jsTypeParam = value type jsValue = js.Value[jsTypeParam] type jsObject = js.Object[jsTypeParam] +type jsArray = js.Array[jsTypeParam] type jsFunction = js.Function[jsTypeParam] type jsError = js.Error[jsTypeParam] diff --git a/scripting/v8engine/array.go b/scripting/v8engine/array.go new file mode 100644 index 000000000..18ae76977 --- /dev/null +++ b/scripting/v8engine/array.go @@ -0,0 +1,25 @@ +package v8engine + +import "github.com/gost-dom/v8go" + +type v8Array struct { + v8Value + Object *v8go.Object +} + +func newV8Array(ctx *V8ScriptContext, o *v8go.Object) jsArray { + return &v8Array{v8Value{ctx, o.Value}, o} +} + +func (a *v8Array) Push(v jsValue) error { + push, err := a.Object.Get("push") + if err != nil { + return err + } + f, err := push.AsFunction() + if err != nil { + return err + } + _, err = f.Call(toV8Value(&a.v8Value), toV8Value(v)) + return err +} diff --git a/scripting/v8engine/callback_context.go b/scripting/v8engine/callback_context.go index b3307b72c..e609ed260 100644 --- a/scripting/v8engine/callback_context.go +++ b/scripting/v8engine/callback_context.go @@ -196,7 +196,7 @@ func (f v8Scope) JSONParse(val string) (jsValue, error) { } -func (f v8Scope) NewArray(values ...jsValue) jsValue { +func (f v8Scope) NewArray(values ...jsValue) jsArray { // Total hack, v8go doesn't expose Array values, so we polyfill the engine var err error arrayOf, err := f.v8ctx.RunScript("Array.of", "gost-polyfills-array") @@ -209,7 +209,11 @@ func (f v8Scope) NewArray(values ...jsValue) jsValue { if err != nil { panic(err) } - return res + obj, ok := res.AsObject() + if !ok { + panic("not ok") + } + return newV8Array(f.V8ScriptContext, obj.(*v8Object).Object) } else { panic("Array.of is not a function") } diff --git a/scripting/v8engine/value.go b/scripting/v8engine/value.go index b9278b0a7..1352a9779 100644 --- a/scripting/v8engine/value.go +++ b/scripting/v8engine/value.go @@ -14,6 +14,7 @@ type jsValue = js.Value[*v8Value] type jsClass = js.Class[*v8Value] type jsFunction = js.Function[*v8Value] type jsObject = js.Object[*v8Value] +type jsArray = js.Array[*v8Value] type jsError = js.Error[*v8Value] func toV8Value(v jsValue) *v8go.Value { From e754d71071db2cb8489256b679a9621ab5ba9200 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Str=C3=B8iman?= Date: Thu, 22 Jan 2026 17:36:39 +0100 Subject: [PATCH 02/12] Implement a simple structured clone --- scripting/internal/js/value.go | 53 +++++++++++++++++ scripting/v8engine/script_host_test.go | 81 ++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) diff --git a/scripting/internal/js/value.go b/scripting/internal/js/value.go index a5d54a6a9..b3de9f88a 100644 --- a/scripting/internal/js/value.go +++ b/scripting/internal/js/value.go @@ -1,5 +1,10 @@ package js +import ( + "errors" + "fmt" +) + // Value represents a value in JavaScript. Referential equality cannot be used // to check if to Value instances represent the same value in JavaScript. Use // StrictEquals to check if two values are equal. @@ -111,3 +116,51 @@ func AsFunction[T any](v Value[T]) (Function[T], bool) { } return v.AsFunction() } + +func Clone[T any](v Value[T], s Scope[T]) (Value[T], error) { + return clone(v, s, nil) +} + +func clone[T any](v Value[T], s Scope[T], objects []Value[T]) (Value[T], error) { + switch { + case v.IsNull(): + return s.Null(), nil + case v.IsUndefined(): + return s.Undefined(), nil + case v.IsString(): + return s.NewString(v.String()), nil + case v.IsFunction(): + //TODO: Use correct error + return nil, errors.New("Serialize function") + } + if o, ok := v.AsObject(); ok { + return cloneObject(o, s, objects) + } + return nil, fmt.Errorf("Unable to clone value: %v", v) +} + +func cloneObject[T any](o Object[T], s Scope[T], knownObjects []Value[T]) (Value[T], error) { + for _, known := range knownObjects { + if o.StrictEquals(known) { + return known, nil + } + } + res := s.NewObject() + knownObjects = append(knownObjects, res) + keys, err := o.Keys() + if err != nil { + return nil, err + } + for _, k := range keys { + oldV, err := o.Get(k) + if err != nil { + return nil, err + } + newV, err := clone(oldV, s, knownObjects) + if err != nil { + return nil, err + } + res.Set(k, newV) + } + return res, nil +} diff --git a/scripting/v8engine/script_host_test.go b/scripting/v8engine/script_host_test.go index b41072e8a..873a43940 100644 --- a/scripting/v8engine/script_host_test.go +++ b/scripting/v8engine/script_host_test.go @@ -1,12 +1,20 @@ package v8engine import ( + "context" + "fmt" + "log/slog" + "net/http" "testing" + "github.com/gost-dom/browser/html" + "github.com/gost-dom/browser/internal/entity" "github.com/gost-dom/browser/internal/testing/browsertest" . "github.com/gost-dom/browser/internal/testing/gomega-matchers" + "github.com/gost-dom/browser/scripting/internal/js" "github.com/gost-dom/browser/scripting/internal/scripttests" "github.com/onsi/gomega" + "github.com/stretchr/testify/assert" ) func TestScriptHostDocumentScriptLoading(t *testing.T) { @@ -27,3 +35,76 @@ func TestScriptHostDocumentScriptLoading(t *testing.T) { func TestBasics(t *testing.T) { scripttests.RunBasicSuite(t, assertEngine) } + +type dummyContext struct { + *entity.Entity + ctx context.Context +} + +func (c dummyContext) Context() context.Context { return c.ctx } +func (c dummyContext) HTTPClient() http.Client { return *http.DefaultClient } +func (c dummyContext) LocationHREF() string { return "http://example.com" } +func (c dummyContext) Logger() *slog.Logger { return nil } + +func TestClone(t *testing.T) { + type T = jsTypeParam + type Global = entity.Entity + + e := newEngine(js.ConfigurerFunc[jsTypeParam](func(e js.ScriptEngine[jsTypeParam]) { + global := e.ConfigureGlobalScope("Global", nil) + global.CreateOperation("store", func(ctx js.CallbackContext[T]) (js.Value[T], error) { + t.Log("Store called") + v, ok := ctx.ConsumeArg() + if !ok { + return nil, ctx.NewTypeError("Missing argument") + } + c, err := js.As[entity.Components](ctx.GlobalThis().NativeValue(), nil) + if err != nil { + return nil, err + } + entity.SetComponentType(c, v) + return nil, nil + }) + + global.CreateOperation("get", func(ctx js.CallbackContext[T]) (js.Value[T], error) { + c, err := js.As[entity.Components](ctx.GlobalThis().NativeValue(), nil) + if err != nil { + return nil, err + } + val, ok := entity.ComponentType[js.Value[T]](c) + if !ok { + return nil, fmt.Errorf("Value missing") + } + fmt.Println("Cloning") + + res, err := js.Clone(val, ctx) + if err != nil { + fmt.Printf("Clone err: %v\n", err) + } + fmt.Println("Cloned") + return res, err + }) + })) + + global1 := new(Global) + global2 := new(Global) + + c1 := e.NewHost(html.ScriptEngineOptions{}).NewContext(dummyContext{global1, t.Context()}) + c2 := e.NewHost(html.ScriptEngineOptions{}).NewContext(dummyContext{global2, t.Context()}) + + assert.NoError(t, c1.Run(` + const a = { + foo: "hello" + } + globalThis.store(a) + `)) + val, ok := entity.ComponentType[js.Value[T]](global1) + assert.True(t, ok) + entity.SetComponentType(global2, val) + + res, err := c2.Eval(` + globalThis.get().foo + `) + assert.NoError(t, err) + assert.Equal(t, "hello", res) +} From 4329903ed19cca710297f4ed3adf7766ccfe7ded Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Str=C3=B8iman?= Date: Fri, 23 Jan 2026 08:16:30 +0100 Subject: [PATCH 03/12] Fix typo in test suite name --- scripting/internal/scripttests/suites.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripting/internal/scripttests/suites.go b/scripting/internal/scripttests/suites.go index ea3e5c990..cc8b827e7 100644 --- a/scripting/internal/scripttests/suites.go +++ b/scripting/internal/scripttests/suites.go @@ -30,7 +30,7 @@ func RunBasicSuite(t *testing.T, e html.ScriptEngine) { func RunSuites(t *testing.T, e html.ScriptEngine) { t.Run("ScriptEngineBehaviour", func(t *testing.T) { testScriptEngineBehaviour(t, e) }) - t.Run("SharowRoot", runSuite(NewShadowRootSuite(e))) + t.Run("ShadowRoot", runSuite(NewShadowRootSuite(e))) t.Run("DocumentFragment", runSuite(NewDocumentFragmentSuite(e))) t.Run("XMLHttpRequest", runSuite(NewXMLHttpRequestSuite(e))) t.Run("Location", runSuite(NewLocationSuite(e))) From 8ca25fc6606f2c803ac6c4061fa78411a98d3baa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Str=C3=B8iman?= Date: Fri, 23 Jan 2026 08:24:52 +0100 Subject: [PATCH 04/12] Extract structuredCloneTest --- .../internal/scripttests/engine_suites.go | 73 +++++++++++++++++++ scripting/internal/scripttests/suites.go | 17 +++++ scripting/sobekengine/sobek_test.go | 8 ++ scripting/v8engine/script_host_test.go | 67 +---------------- 4 files changed, 102 insertions(+), 63 deletions(-) create mode 100644 scripting/internal/scripttests/engine_suites.go diff --git a/scripting/internal/scripttests/engine_suites.go b/scripting/internal/scripttests/engine_suites.go new file mode 100644 index 000000000..fcf9a2456 --- /dev/null +++ b/scripting/internal/scripttests/engine_suites.go @@ -0,0 +1,73 @@ +package scripttests + +import ( + "fmt" + "testing" + + "github.com/gost-dom/browser/html" + "github.com/gost-dom/browser/internal/entity" + "github.com/gost-dom/browser/scripting/internal/js" + "github.com/stretchr/testify/assert" +) + +// RunScriptEngineSuites runs test suites of the script engine without a +// predefined global scope. In contrast to [RunSuites] that expect an engine +// configured for the global scope in the Window realm. +func RunScriptEngineSuites[T any](t *testing.T, f ScriptEngineFactory[T]) { + type Global = entity.Entity + + e := f(js.ConfigurerFunc[T](func(e js.ScriptEngine[T]) { + global := e.ConfigureGlobalScope("Global", nil) + global.CreateOperation("store", func(ctx js.CallbackContext[T]) (js.Value[T], error) { + v, ok := ctx.ConsumeArg() + if !ok { + return nil, ctx.NewTypeError("Missing argument") + } + c, err := js.As[entity.Components](ctx.GlobalThis().NativeValue(), nil) + if err != nil { + return nil, err + } + entity.SetComponentType(c, v) + return nil, nil + }) + + global.CreateOperation("get", func(ctx js.CallbackContext[T]) (js.Value[T], error) { + c, err := js.As[entity.Components](ctx.GlobalThis().NativeValue(), nil) + if err != nil { + return nil, err + } + val, ok := entity.ComponentType[js.Value[T]](c) + if !ok { + return nil, fmt.Errorf("Value missing") + } + + res, err := js.Clone(val, ctx) + if err != nil { + fmt.Printf("Clone err: %v\n", err) + } + return res, err + }) + })) + + global1 := new(Global) + global2 := new(Global) + + c1 := e.NewHost(html.ScriptEngineOptions{}).NewContext(dummyContext{global1, t.Context()}) + c2 := e.NewHost(html.ScriptEngineOptions{}).NewContext(dummyContext{global2, t.Context()}) + + assert.NoError(t, c1.Run(` + const a = { + foo: "hello" + } + globalThis.store(a) + `)) + val, ok := entity.ComponentType[js.Value[T]](global1) + assert.True(t, ok) + entity.SetComponentType(global2, val) + + res, err := c2.Eval(` + globalThis.get().foo + `) + assert.NoError(t, err) + assert.Equal(t, "hello", res) +} diff --git a/scripting/internal/scripttests/suites.go b/scripting/internal/scripttests/suites.go index cc8b827e7..1667518c6 100644 --- a/scripting/internal/scripttests/suites.go +++ b/scripting/internal/scripttests/suites.go @@ -1,12 +1,17 @@ package scripttests import ( + "context" + "log/slog" + "net/http" "testing" "github.com/gost-dom/browser/html" + "github.com/gost-dom/browser/internal/entity" "github.com/gost-dom/browser/internal/testing/browsertest" "github.com/gost-dom/browser/scripting/internal/dom/domsuite" "github.com/gost-dom/browser/scripting/internal/html/htmlsuite" + "github.com/gost-dom/browser/scripting/internal/js" "github.com/gost-dom/browser/scripting/internal/uievents/uieventssuite" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" @@ -28,6 +33,8 @@ func RunBasicSuite(t *testing.T, e html.ScriptEngine) { assert.True(t, w.MustEval("window === globalThis").(bool)) } +type ScriptEngineFactory[T any] = func(js.Configurer[T]) html.ScriptEngine + func RunSuites(t *testing.T, e html.ScriptEngine) { t.Run("ScriptEngineBehaviour", func(t *testing.T) { testScriptEngineBehaviour(t, e) }) t.Run("ShadowRoot", runSuite(NewShadowRootSuite(e))) @@ -58,3 +65,13 @@ func RunSuites(t *testing.T, e html.ScriptEngine) { t.Run("html", func(t *testing.T) { htmlsuite.RunHtmlSuite(t, e) }) t.Run("dom", func(t *testing.T) { domsuite.RunDomSuite(t, e) }) } + +type dummyContext struct { + *entity.Entity + ctx context.Context +} + +func (c dummyContext) Context() context.Context { return c.ctx } +func (c dummyContext) HTTPClient() http.Client { return *http.DefaultClient } +func (c dummyContext) LocationHREF() string { return "http://example.com" } +func (c dummyContext) Logger() *slog.Logger { return nil } diff --git a/scripting/sobekengine/sobek_test.go b/scripting/sobekengine/sobek_test.go index 8e19f6aec..b4e414dd5 100644 --- a/scripting/sobekengine/sobek_test.go +++ b/scripting/sobekengine/sobek_test.go @@ -3,7 +3,9 @@ package sobekengine import ( "testing" + "github.com/gost-dom/browser/html" "github.com/gost-dom/browser/scripting/internal" + "github.com/gost-dom/browser/scripting/internal/js" "github.com/gost-dom/browser/scripting/internal/scripttests" "github.com/gost-dom/browser/scripting/internal/testing/jsassert" ) @@ -35,6 +37,12 @@ func TestBasics(t *testing.T) { scripttests.RunBasicSuite(t, assertEngine) } +func TestSobekEngine(t *testing.T) { + scripttests.RunScriptEngineSuites(t, + func(c js.Configurer[jsTypeParam]) html.ScriptEngine { return newEngine(c) }, + ) +} + func init() { configurer := internal.CreateWindowsConfigurer[jsTypeParam]() configurer.AddConfigurerFunc(jsassert.Configure) diff --git a/scripting/v8engine/script_host_test.go b/scripting/v8engine/script_host_test.go index 873a43940..430c722b7 100644 --- a/scripting/v8engine/script_host_test.go +++ b/scripting/v8engine/script_host_test.go @@ -2,7 +2,6 @@ package v8engine import ( "context" - "fmt" "log/slog" "net/http" "testing" @@ -14,7 +13,6 @@ import ( "github.com/gost-dom/browser/scripting/internal/js" "github.com/gost-dom/browser/scripting/internal/scripttests" "github.com/onsi/gomega" - "github.com/stretchr/testify/assert" ) func TestScriptHostDocumentScriptLoading(t *testing.T) { @@ -46,65 +44,8 @@ func (c dummyContext) HTTPClient() http.Client { return *http.DefaultClient } func (c dummyContext) LocationHREF() string { return "http://example.com" } func (c dummyContext) Logger() *slog.Logger { return nil } -func TestClone(t *testing.T) { - type T = jsTypeParam - type Global = entity.Entity - - e := newEngine(js.ConfigurerFunc[jsTypeParam](func(e js.ScriptEngine[jsTypeParam]) { - global := e.ConfigureGlobalScope("Global", nil) - global.CreateOperation("store", func(ctx js.CallbackContext[T]) (js.Value[T], error) { - t.Log("Store called") - v, ok := ctx.ConsumeArg() - if !ok { - return nil, ctx.NewTypeError("Missing argument") - } - c, err := js.As[entity.Components](ctx.GlobalThis().NativeValue(), nil) - if err != nil { - return nil, err - } - entity.SetComponentType(c, v) - return nil, nil - }) - - global.CreateOperation("get", func(ctx js.CallbackContext[T]) (js.Value[T], error) { - c, err := js.As[entity.Components](ctx.GlobalThis().NativeValue(), nil) - if err != nil { - return nil, err - } - val, ok := entity.ComponentType[js.Value[T]](c) - if !ok { - return nil, fmt.Errorf("Value missing") - } - fmt.Println("Cloning") - - res, err := js.Clone(val, ctx) - if err != nil { - fmt.Printf("Clone err: %v\n", err) - } - fmt.Println("Cloned") - return res, err - }) - })) - - global1 := new(Global) - global2 := new(Global) - - c1 := e.NewHost(html.ScriptEngineOptions{}).NewContext(dummyContext{global1, t.Context()}) - c2 := e.NewHost(html.ScriptEngineOptions{}).NewContext(dummyContext{global2, t.Context()}) - - assert.NoError(t, c1.Run(` - const a = { - foo: "hello" - } - globalThis.store(a) - `)) - val, ok := entity.ComponentType[js.Value[T]](global1) - assert.True(t, ok) - entity.SetComponentType(global2, val) - - res, err := c2.Eval(` - globalThis.get().foo - `) - assert.NoError(t, err) - assert.Equal(t, "hello", res) +func TestV8Engine(t *testing.T) { + scripttests.RunScriptEngineSuites(t, + func(c js.Configurer[jsTypeParam]) html.ScriptEngine { return newEngine(c) }, + ) } From 15ef28fecfee2a64ede89b3619662690103bd280 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Str=C3=B8iman?= Date: Mon, 2 Feb 2026 09:25:00 +0100 Subject: [PATCH 05/12] Support cloning numbers and booleans --- .../testing/htmltest/script_context_helper.go | 25 +++++++++++++++++ scripting/internal/js/callback_context.go | 1 + scripting/internal/js/value.go | 6 ++++ .../internal/scripttests/engine_suites.go | 28 +++++++++++++------ scripting/sobekengine/scope.go | 4 +++ scripting/sobekengine/value.go | 13 +++++++-- scripting/v8engine/callback_context.go | 3 +- scripting/v8engine/script.go | 20 ++++++------- scripting/v8engine/value.go | 10 ++++--- 9 files changed, 82 insertions(+), 28 deletions(-) create mode 100644 internal/testing/htmltest/script_context_helper.go diff --git a/internal/testing/htmltest/script_context_helper.go b/internal/testing/htmltest/script_context_helper.go new file mode 100644 index 000000000..b1da26e2d --- /dev/null +++ b/internal/testing/htmltest/script_context_helper.go @@ -0,0 +1,25 @@ +package htmltest + +import ( + "testing" + + "github.com/gost-dom/browser/html" +) + +type ScriptContextHelper struct { + html.ScriptContext + t testing.TB +} + +func NewScriptContextHelper(t testing.TB, ctx html.ScriptContext) ScriptContextHelper { + return ScriptContextHelper{ctx, t} +} + +func (h ScriptContextHelper) MustEval(script string) any { + res, err := h.Eval(script) + if err != nil { + h.t.Helper() + h.t.Fatalf("Script error: %v", err) + } + return res +} diff --git a/scripting/internal/js/callback_context.go b/scripting/internal/js/callback_context.go index fe498fc06..a15ca9f99 100644 --- a/scripting/internal/js/callback_context.go +++ b/scripting/internal/js/callback_context.go @@ -169,6 +169,7 @@ type ValueFactory[T any] interface { NewPromise() Promise[T] NewString(string) Value[T] + NewNumber(float64) Value[T] NewBoolean(bool) Value[T] NewObject() Object[T] NewFunction(Callback[T]) Function[T] diff --git a/scripting/internal/js/value.go b/scripting/internal/js/value.go index b3de9f88a..ac21a10c3 100644 --- a/scripting/internal/js/value.go +++ b/scripting/internal/js/value.go @@ -20,6 +20,7 @@ type Value[T any] interface { Self() T String() string + Number() float64 Int32() int32 Uint32() uint32 Boolean() bool @@ -28,6 +29,7 @@ type Value[T any] interface { IsNull() bool IsSymbol() bool IsString() bool + IsNumber() bool IsObject() bool IsBoolean() bool IsFunction() bool @@ -129,6 +131,10 @@ func clone[T any](v Value[T], s Scope[T], objects []Value[T]) (Value[T], error) return s.Undefined(), nil case v.IsString(): return s.NewString(v.String()), nil + case v.IsNumber(): + return s.NewNumber(v.Number()), nil + case v.IsBoolean(): + return s.NewBoolean(v.Boolean()), nil case v.IsFunction(): //TODO: Use correct error return nil, errors.New("Serialize function") diff --git a/scripting/internal/scripttests/engine_suites.go b/scripting/internal/scripttests/engine_suites.go index fcf9a2456..35427ff4f 100644 --- a/scripting/internal/scripttests/engine_suites.go +++ b/scripting/internal/scripttests/engine_suites.go @@ -6,6 +6,7 @@ import ( "github.com/gost-dom/browser/html" "github.com/gost-dom/browser/internal/entity" + "github.com/gost-dom/browser/internal/testing/htmltest" "github.com/gost-dom/browser/scripting/internal/js" "github.com/stretchr/testify/assert" ) @@ -43,7 +44,7 @@ func RunScriptEngineSuites[T any](t *testing.T, f ScriptEngineFactory[T]) { res, err := js.Clone(val, ctx) if err != nil { - fmt.Printf("Clone err: %v\n", err) + t.Errorf("Clone error: %v", err) } return res, err }) @@ -52,12 +53,21 @@ func RunScriptEngineSuites[T any](t *testing.T, f ScriptEngineFactory[T]) { global1 := new(Global) global2 := new(Global) - c1 := e.NewHost(html.ScriptEngineOptions{}).NewContext(dummyContext{global1, t.Context()}) - c2 := e.NewHost(html.ScriptEngineOptions{}).NewContext(dummyContext{global2, t.Context()}) + c1 := htmltest.NewScriptContextHelper( + t, + e.NewHost(html.ScriptEngineOptions{}).NewContext(dummyContext{global1, t.Context()}), + ) + c2 := htmltest.NewScriptContextHelper( + t, + e.NewHost(html.ScriptEngineOptions{}).NewContext(dummyContext{global2, t.Context()}), + ) assert.NoError(t, c1.Run(` const a = { - foo: "hello" + stringVal: "hello", + numberVal: 42.5, + trueVal: true, + falseVal: false, } globalThis.store(a) `)) @@ -65,9 +75,9 @@ func RunScriptEngineSuites[T any](t *testing.T, f ScriptEngineFactory[T]) { assert.True(t, ok) entity.SetComponentType(global2, val) - res, err := c2.Eval(` - globalThis.get().foo - `) - assert.NoError(t, err) - assert.Equal(t, "hello", res) + assert.NoError(t, c2.Run("const cloned = globalThis.get()")) + assert.Equal(t, "hello", c2.MustEval("cloned.stringVal")) + assert.Equal(t, 42.5, c2.MustEval("cloned.numberVal")) + assert.True(t, c2.MustEval("cloned.trueVal").(bool)) + assert.False(t, c2.MustEval("cloned.falseVal").(bool)) } diff --git a/scripting/sobekengine/scope.go b/scripting/sobekengine/scope.go index d744c163c..0f750ef96 100644 --- a/scripting/sobekengine/scope.go +++ b/scripting/sobekengine/scope.go @@ -100,6 +100,10 @@ func (f scope) NewString(v string) js.Value[jsTypeParam] { return newValue(f.scriptContext, f.vm.ToValue(v)) } +func (f scope) NewNumber(v float64) js.Value[jsTypeParam] { + return newValue(f.scriptContext, f.vm.ToValue(v)) +} + // NewTypeError implements [js.ValueFactory]. func (c scope) NewTypeError(v string) js.Error[jsTypeParam] { sobekErrVal := c.vm.NewTypeError(v) diff --git a/scripting/sobekengine/value.go b/scripting/sobekengine/value.go index 880ce1370..914ffbfb7 100644 --- a/scripting/sobekengine/value.go +++ b/scripting/sobekengine/value.go @@ -41,16 +41,18 @@ func (v value) AsFunction() (js.Function[jsTypeParam], bool) { } func (v value) AsObject() (jsObject, bool) { - if o := v.value.ToObject(v.ctx.vm); o != nil { - return newObject(v.ctx, o), true + if !v.IsObject() { + return nil, false } - return nil, false + o := v.value.ToObject(v.ctx.vm) + return newObject(v.ctx, o), true } func (v value) IsNull() bool { return sobek.IsNull(v.value) } func (v value) IsUndefined() bool { return sobek.IsUndefined(v.value) } func (v value) IsString() bool { return sobek.IsString(v.value) } +func (v value) IsNumber() bool { return sobek.IsNumber(v.value) } func (v value) IsBoolean() bool { // Sobek doesn't expose an IsBoolean function, so resort to calling 'typeof' @@ -81,6 +83,11 @@ func (v value) IsFunction() bool { } func (v value) String() string { return v.value.String() } +func (v value) Number() float64 { + res, _ := v.value.Export().(float64) + return res +} + func (v value) Boolean() bool { return v.value.ToBoolean() } func (v value) Int32() int32 { return int32(v.value.ToInteger()) } func (v value) Uint32() uint32 { return uint32(v.value.ToInteger()) } diff --git a/scripting/v8engine/callback_context.go b/scripting/v8engine/callback_context.go index e609ed260..028e01265 100644 --- a/scripting/v8engine/callback_context.go +++ b/scripting/v8engine/callback_context.go @@ -116,7 +116,8 @@ func (f v8Scope) iso() *v8go.Isolate { return f.host.iso } func (f v8Scope) Undefined() jsValue { return f.toJSValue(v8go.Undefined(f.iso())) } func (f v8Scope) Null() jsValue { return f.toJSValue(v8go.Null(f.iso())) } -func (f v8Scope) NewString(val string) jsValue { return f.newV8Value(val) } +func (f v8Scope) NewString(val string) jsValue { return f.newV8Value(val) } +func (f v8Scope) NewNumber(val float64) jsValue { return f.newV8Value(val) } func (f v8Scope) NewObject() jsObject { val, err := f.V8ScriptContext.v8ctx.RunScript("({})", "gost-dom/object") diff --git a/scripting/v8engine/script.go b/scripting/v8engine/script.go index d029a2749..7429b3c75 100644 --- a/scripting/v8engine/script.go +++ b/scripting/v8engine/script.go @@ -26,22 +26,20 @@ func (s V8Script) Eval() (any, error) { } func v8ValueToGoValue(result *v8go.Value) (any, error) { - if result == nil { + switch { + case result == nil: return nil, nil - } - if result.IsBoolean() { + case result.IsBoolean(): return result.Boolean(), nil - } - if result.IsInt32() { + case result.IsInt32(): return result.Int32(), nil - } - if result.IsString() { + case result.IsNumber(): + return result.Number(), nil + case result.IsString(): return result.String(), nil - } - if result.IsNull() { + case result.IsNull(): return nil, nil - } - if result.IsUndefined() { + case result.IsUndefined(): return nil, nil } if o, err := result.AsObject(); err == nil { diff --git a/scripting/v8engine/value.go b/scripting/v8engine/value.go index 1352a9779..ca413eea7 100644 --- a/scripting/v8engine/value.go +++ b/scripting/v8engine/value.go @@ -55,15 +55,17 @@ func (v *v8Value) v8Value() *v8go.Value { return v.Value } -func (v v8Value) String() string { return v.Value.String() } -func (v v8Value) Int32() int32 { return v.Value.Int32() } -func (v v8Value) Uint32() uint32 { return v.Value.Uint32() } -func (v v8Value) Boolean() bool { return v.Value.Boolean() } +func (v v8Value) String() string { return v.Value.String() } +func (v v8Value) Number() float64 { return v.Value.Number() } +func (v v8Value) Int32() int32 { return v.Value.Int32() } +func (v v8Value) Uint32() uint32 { return v.Value.Uint32() } +func (v v8Value) Boolean() bool { return v.Value.Boolean() } func (v v8Value) IsUndefined() bool { return v.Value == nil || v.Value.IsUndefined() } func (v v8Value) IsNull() bool { return v.Value.IsNull() } func (v v8Value) IsBoolean() bool { return v.Value.IsBoolean() } func (v v8Value) IsString() bool { return v.Value.IsString() } +func (v v8Value) IsNumber() bool { return v.Value.IsNumber() } func (v v8Value) IsSymbol() bool { return v.Value.IsSymbol() } func (v v8Value) IsObject() bool { return v.Value.IsObject() } func (v v8Value) IsFunction() bool { return v.Value.IsFunction() } From c86ea8b5cb5a089e5001af7fce1296843618d46d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Str=C3=B8iman?= Date: Mon, 2 Feb 2026 13:50:24 +0100 Subject: [PATCH 06/12] Fix structured clone duplicate object detection --- scripting/internal/js/value.go | 15 +++++++++------ scripting/internal/scripttests/engine_suites.go | 6 ++++++ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/scripting/internal/js/value.go b/scripting/internal/js/value.go index ac21a10c3..32ea90304 100644 --- a/scripting/internal/js/value.go +++ b/scripting/internal/js/value.go @@ -120,10 +120,11 @@ func AsFunction[T any](v Value[T]) (Function[T], bool) { } func Clone[T any](v Value[T], s Scope[T]) (Value[T], error) { - return clone(v, s, nil) + var objects [][2]Value[T] + return clone(v, s, &objects) } -func clone[T any](v Value[T], s Scope[T], objects []Value[T]) (Value[T], error) { +func clone[T any](v Value[T], s Scope[T], objects *[][2]Value[T]) (Value[T], error) { switch { case v.IsNull(): return s.Null(), nil @@ -145,14 +146,16 @@ func clone[T any](v Value[T], s Scope[T], objects []Value[T]) (Value[T], error) return nil, fmt.Errorf("Unable to clone value: %v", v) } -func cloneObject[T any](o Object[T], s Scope[T], knownObjects []Value[T]) (Value[T], error) { - for _, known := range knownObjects { +func cloneObject[T any](o Object[T], s Scope[T], knownObjects *[][2]Value[T]) (Value[T], error) { + for _, pair := range *knownObjects { + known := pair[0] + res := pair[1] if o.StrictEquals(known) { - return known, nil + return res, nil } } res := s.NewObject() - knownObjects = append(knownObjects, res) + *knownObjects = append(*knownObjects, [2]Value[T]{o, res}) keys, err := o.Keys() if err != nil { return nil, err diff --git a/scripting/internal/scripttests/engine_suites.go b/scripting/internal/scripttests/engine_suites.go index 35427ff4f..04e79b408 100644 --- a/scripting/internal/scripttests/engine_suites.go +++ b/scripting/internal/scripttests/engine_suites.go @@ -63,11 +63,16 @@ func RunScriptEngineSuites[T any](t *testing.T, f ScriptEngineFactory[T]) { ) assert.NoError(t, c1.Run(` + const b = { + id: "b", + } const a = { stringVal: "hello", numberVal: 42.5, trueVal: true, falseVal: false, + b1: b, + b2: b, } globalThis.store(a) `)) @@ -80,4 +85,5 @@ func RunScriptEngineSuites[T any](t *testing.T, f ScriptEngineFactory[T]) { assert.Equal(t, 42.5, c2.MustEval("cloned.numberVal")) assert.True(t, c2.MustEval("cloned.trueVal").(bool)) assert.False(t, c2.MustEval("cloned.falseVal").(bool)) + assert.True(t, c2.MustEval("cloned.b1 === cloned.b2").(bool)) } From 38a93d009544c225504a7bb89df75d961f7d5195 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Str=C3=B8iman?= Date: Mon, 2 Feb 2026 14:36:11 +0100 Subject: [PATCH 07/12] Structured clone supports arrays --- scripting/internal/js/value.go | 49 ++++++++++++++++++- .../internal/scripttests/engine_suites.go | 7 +++ scripting/sobekengine/script_context.go | 10 ++++ scripting/sobekengine/value.go | 1 + scripting/v8engine/value.go | 1 + 5 files changed, 66 insertions(+), 2 deletions(-) diff --git a/scripting/internal/js/value.go b/scripting/internal/js/value.go index 32ea90304..007fd1664 100644 --- a/scripting/internal/js/value.go +++ b/scripting/internal/js/value.go @@ -3,6 +3,8 @@ package js import ( "errors" "fmt" + + "github.com/gost-dom/browser/internal/constants" ) // Value represents a value in JavaScript. Referential equality cannot be used @@ -33,6 +35,7 @@ type Value[T any] interface { IsObject() bool IsBoolean() bool IsFunction() bool + IsArray() bool AsFunction() (Function[T], bool) AsObject() (Object[T], bool) @@ -136,6 +139,8 @@ func clone[T any](v Value[T], s Scope[T], objects *[][2]Value[T]) (Value[T], err return s.NewNumber(v.Number()), nil case v.IsBoolean(): return s.NewBoolean(v.Boolean()), nil + case v.IsArray(): + return cloneArray(v, s, objects) case v.IsFunction(): //TODO: Use correct error return nil, errors.New("Serialize function") @@ -146,14 +151,54 @@ func clone[T any](v Value[T], s Scope[T], objects *[][2]Value[T]) (Value[T], err return nil, fmt.Errorf("Unable to clone value: %v", v) } -func cloneObject[T any](o Object[T], s Scope[T], knownObjects *[][2]Value[T]) (Value[T], error) { +func findKnownValue[T any](o Value[T], knownObjects *[][2]Value[T]) (Value[T], bool) { for _, pair := range *knownObjects { known := pair[0] res := pair[1] if o.StrictEquals(known) { - return res, nil + return res, true } } + return nil, false +} + +func cloneArray[T any]( + v Value[T], + s Scope[T], + knownObjects *[][2]Value[T], +) (Value[T], error) { + if existing, ok := findKnownValue(v, knownObjects); ok { + return existing, nil + } + o, ok := v.AsObject() + if !ok { + return nil, fmt.Errorf( + "Object was an array, but not convertible to object. %w", + constants.ErrGostDomBug, + ) + } + res := s.NewArray() + *knownObjects = append(*knownObjects, [2]Value[T]{v, res}) + + for v, err := range Iterate(o) { + if err != nil { + return nil, err + } + cloned, err := clone(v, s, knownObjects) + if err != nil { + return nil, err + } + res.Push(cloned) + } + // TODO: Potential bug here, if the array references itself recursively, + // this would lead to a stack overflow error. + return res, nil +} + +func cloneObject[T any](o Object[T], s Scope[T], knownObjects *[][2]Value[T]) (Value[T], error) { + if existing, ok := findKnownValue(o, knownObjects); ok { + return existing, nil + } res := s.NewObject() *knownObjects = append(*knownObjects, [2]Value[T]{o, res}) keys, err := o.Keys() diff --git a/scripting/internal/scripttests/engine_suites.go b/scripting/internal/scripttests/engine_suites.go index 04e79b408..63d2ba0d1 100644 --- a/scripting/internal/scripttests/engine_suites.go +++ b/scripting/internal/scripttests/engine_suites.go @@ -66,6 +66,9 @@ func RunScriptEngineSuites[T any](t *testing.T, f ScriptEngineFactory[T]) { const b = { id: "b", } + const arr = [1,2,3] + const recursiveArray = [1,2,3] + recursiveArray.push(recursiveArray) const a = { stringVal: "hello", numberVal: 42.5, @@ -73,6 +76,9 @@ func RunScriptEngineSuites[T any](t *testing.T, f ScriptEngineFactory[T]) { falseVal: false, b1: b, b2: b, + arr1: arr, + arr2: arr, + // recursiveArray, } globalThis.store(a) `)) @@ -86,4 +92,5 @@ func RunScriptEngineSuites[T any](t *testing.T, f ScriptEngineFactory[T]) { assert.True(t, c2.MustEval("cloned.trueVal").(bool)) assert.False(t, c2.MustEval("cloned.falseVal").(bool)) assert.True(t, c2.MustEval("cloned.b1 === cloned.b2").(bool)) + assert.Equal(t, "1,2,3", c2.MustEval("cloned.arr1.join(',')")) } diff --git a/scripting/sobekengine/script_context.go b/scripting/sobekengine/script_context.go index 87a80bd61..1f5038a9a 100644 --- a/scripting/sobekengine/script_context.go +++ b/scripting/sobekengine/script_context.go @@ -371,7 +371,17 @@ func (c *scriptContext) typeOf(v value) string { panic(err) } return res.String() +} +func (c *scriptContext) isArray(v value) bool { + vm := c.vm + fn, _ := vm.RunString("x => Array.isArray(x)") + fnn, _ := sobek.AssertFunction(fn) + res, err := fnn(vm.GlobalObject(), v.value) + if err != nil { + panic(err) + } + return res.ToBoolean() } /* -------- script -------- */ diff --git a/scripting/sobekengine/value.go b/scripting/sobekengine/value.go index 914ffbfb7..882e95662 100644 --- a/scripting/sobekengine/value.go +++ b/scripting/sobekengine/value.go @@ -53,6 +53,7 @@ func (v value) IsNull() bool { return sobek.IsNull(v.value) } func (v value) IsUndefined() bool { return sobek.IsUndefined(v.value) } func (v value) IsString() bool { return sobek.IsString(v.value) } func (v value) IsNumber() bool { return sobek.IsNumber(v.value) } +func (v value) IsArray() bool { return v.ctx.isArray(v) } func (v value) IsBoolean() bool { // Sobek doesn't expose an IsBoolean function, so resort to calling 'typeof' diff --git a/scripting/v8engine/value.go b/scripting/v8engine/value.go index ca413eea7..cf6d50e6e 100644 --- a/scripting/v8engine/value.go +++ b/scripting/v8engine/value.go @@ -68,6 +68,7 @@ func (v v8Value) IsString() bool { return v.Value.IsString() } func (v v8Value) IsNumber() bool { return v.Value.IsNumber() } func (v v8Value) IsSymbol() bool { return v.Value.IsSymbol() } func (v v8Value) IsObject() bool { return v.Value.IsObject() } +func (v v8Value) IsArray() bool { return v.Value.IsArray() } func (v v8Value) IsFunction() bool { return v.Value.IsFunction() } func (v v8Value) StrictEquals( From f46a28406a0aaf86b6de171fbee68ed9982c7045 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Str=C3=B8iman?= Date: Mon, 2 Feb 2026 18:14:12 +0100 Subject: [PATCH 08/12] fixup! Support cloning numbers and booleans --- scripting/sobekengine/value.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/scripting/sobekengine/value.go b/scripting/sobekengine/value.go index 882e95662..97086a68c 100644 --- a/scripting/sobekengine/value.go +++ b/scripting/sobekengine/value.go @@ -84,10 +84,8 @@ func (v value) IsFunction() bool { } func (v value) String() string { return v.value.String() } -func (v value) Number() float64 { - res, _ := v.value.Export().(float64) - return res -} + +func (v value) Number() float64 { return v.value.ToFloat() } func (v value) Boolean() bool { return v.value.ToBoolean() } func (v value) Int32() int32 { return int32(v.value.ToInteger()) } From d52a482602c4ef5558e75c13e4cf4ce12d983348 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Str=C3=B8iman?= Date: Mon, 2 Feb 2026 18:15:21 +0100 Subject: [PATCH 09/12] fixup! Structured clone supports arrays --- scripting/internal/scripttests/engine_suites.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripting/internal/scripttests/engine_suites.go b/scripting/internal/scripttests/engine_suites.go index 63d2ba0d1..c3d5f688a 100644 --- a/scripting/internal/scripttests/engine_suites.go +++ b/scripting/internal/scripttests/engine_suites.go @@ -78,7 +78,7 @@ func RunScriptEngineSuites[T any](t *testing.T, f ScriptEngineFactory[T]) { b2: b, arr1: arr, arr2: arr, - // recursiveArray, + recursiveArray, } globalThis.store(a) `)) From 4b99cf4c8d65955b3e85739d2c635d8d3bfa0ded Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Str=C3=B8iman?= Date: Mon, 2 Feb 2026 16:49:47 +0100 Subject: [PATCH 10/12] test: Move t.Helper() to top of function --- internal/testing/htmltest/script_context_helper.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/testing/htmltest/script_context_helper.go b/internal/testing/htmltest/script_context_helper.go index b1da26e2d..1f4c7fc68 100644 --- a/internal/testing/htmltest/script_context_helper.go +++ b/internal/testing/htmltest/script_context_helper.go @@ -16,9 +16,9 @@ func NewScriptContextHelper(t testing.TB, ctx html.ScriptContext) ScriptContextH } func (h ScriptContextHelper) MustEval(script string) any { + h.t.Helper() res, err := h.Eval(script) if err != nil { - h.t.Helper() h.t.Fatalf("Script error: %v", err) } return res From 4acce2dffb0b35fdb7eb10e402effde4568515e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Str=C3=B8iman?= Date: Mon, 2 Feb 2026 16:55:21 +0100 Subject: [PATCH 11/12] refactor(js): Consolidate type check helpers --- scripting/internal/js/arguments.go | 2 -- scripting/internal/js/value.go | 11 ++++++----- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/scripting/internal/js/arguments.go b/scripting/internal/js/arguments.go index 951987906..a7a11369e 100644 --- a/scripting/internal/js/arguments.go +++ b/scripting/internal/js/arguments.go @@ -48,8 +48,6 @@ func ConsumeArgument[T, U any]( } } -func IsUndefined[T any](v Value[T]) bool { return v == nil || v.IsUndefined() } - func ConsumeRestArguments[T, U any]( args CallbackContext[U], name string, diff --git a/scripting/internal/js/value.go b/scripting/internal/js/value.go index 007fd1664..7dee7bb1e 100644 --- a/scripting/internal/js/value.go +++ b/scripting/internal/js/value.go @@ -43,6 +43,12 @@ type Value[T any] interface { StrictEquals(Value[T]) bool } +// IsNullish returns whether a JavaScript value is null or undefined. +func IsNullish[T any](v Value[T]) bool { return v == nil || v.IsNull() || v.IsUndefined() } + +func IsUndefined[T any](v Value[T]) bool { return v == nil || v.IsUndefined() } +func IsBoolean[T any](v Value[T]) bool { return v != nil && v.IsBoolean() } + type Function[T any] interface { Value[T] @@ -110,11 +116,6 @@ type Promise[T any] interface { Reject(Value[T]) } -// IsNullish returns whether a JavaScript value is null or undefined. -func IsNullish[T any](v Value[T]) bool { return v == nil || v.IsNull() || v.IsUndefined() } - -func IsBoolean[T any](v Value[T]) bool { return v != nil && v.IsBoolean() } - func AsFunction[T any](v Value[T]) (Function[T], bool) { if IsNullish(v) { return nil, false From af3e7edc6db3d864e0184b2d77ee10778cd3a71e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20Str=C3=B8iman?= Date: Mon, 2 Feb 2026 16:56:09 +0100 Subject: [PATCH 12/12] Handle potential nil values in Clone --- scripting/internal/js/value.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/scripting/internal/js/value.go b/scripting/internal/js/value.go index 7dee7bb1e..7790d2e35 100644 --- a/scripting/internal/js/value.go +++ b/scripting/internal/js/value.go @@ -130,6 +130,8 @@ func Clone[T any](v Value[T], s Scope[T]) (Value[T], error) { func clone[T any](v Value[T], s Scope[T], objects *[][2]Value[T]) (Value[T], error) { switch { + case v == nil || v.IsUndefined(): + return s.Undefined(), nil case v.IsNull(): return s.Null(), nil case v.IsUndefined():