From 03b744c746a9a0bf221227a0c2de45a884855f83 Mon Sep 17 00:00:00 2001 From: fromelicks <108532072+fromelicks@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:49:51 +0200 Subject: [PATCH 1/4] Support JSON.jl v1 --- Project.toml | 4 ++-- src/syntax.jl | 58 ++++++++++++++++++++++++++++++++++++-------------- test/syntax.jl | 13 +++++++++++ 3 files changed, 57 insertions(+), 18 deletions(-) diff --git a/Project.toml b/Project.toml index dea941d5..46b5824b 100644 --- a/Project.toml +++ b/Project.toml @@ -1,7 +1,7 @@ name = "WebIO" uuid = "0f1e0344-ec1d-5b48-a673-e5cf874b6c29" license = "MIT" -version = "0.8.21" +version = "0.8.22" [deps] AssetRegistry = "bf4720bc-e11a-5d0c-854e-bdca1663c893" @@ -23,7 +23,7 @@ Widgets = "cc8bc4a8-27d6-5769-a93b-9d913e69aa62" AssetRegistry = "0.1.0" FunctionalCollections = "0.5.0" JSExpr = "0.5" -JSON = "0.18, 0.19, 0.20, 0.21" +JSON = "0.18, 0.19, 0.20, 0.21, 1" Observables = "0.5" Requires = "0.4.4, 0.5, 1.0.0" WebSockets = "1.5.0, 1.6.0" diff --git a/src/syntax.jl b/src/syntax.jl index e91fe37b..0ac93469 100644 --- a/src/syntax.jl +++ b/src/syntax.jl @@ -190,7 +190,13 @@ tojs(x) = x Print Javascript code to `io` that constructs the equivalent of `x`. """ -showjs(io, x::Any) = JSON.show_json(io, JSEvalSerialization(), x) +@static if isdefined(JSON, :JSONStyle) # JSON.jl >= 1 + # `sort_keys=false` keeps object keys in `Dict` iteration order, which is + # what JSON.jl < 1 does; JSON.jl v1 sorts `Dict` keys by default. + showjs(io, x::Any) = JSON.json(io, x; style=JSEvalSerialization(), sort_keys=false) +else + showjs(io, x::Any) = JSON.show_json(io, JSEvalSerialization(), x) +end showjs(io, x::AbstractString) = write(io, JSON.json(x)) """ @@ -226,23 +232,43 @@ Base.:(==)(x::JSString, y::JSString) = x.s==y.s JSON.lower(x::JSString) = JSON.lower(x.s) -const JSONContext = JSON.Writer.StructuralContext -const JSONSerialization = JSON.Serializations.CommonSerialization - -struct JSEvalSerialization <: JSONSerialization end - +# Only honoured on JSON.jl < 1; see below. const verbose_json = Ref(false) -# adapted (very slightly) from JSON.jl test/serializer.jl -function JSON.show_json(io::JSONContext, ::JSEvalSerialization, x::JSString) - if verbose_json[] - first = true - for line in split(x.s, '\n') - !first && JSON.indent(io) - first = false - Base.print(io, line) +# `JSEvalSerialization` is the serialization under which a `JSString` is spliced +# in as raw JavaScript rather than quoted as a JSON string. It is what `showjs`, +# and hence interpolation into a `js"..."` literal, uses. The default +# serialization still quotes `JSString`s (see `JSON.lower` above), which is what +# keeps node props such as event handlers valid JSON. +# +# JSON.jl v1 replaced the `show_json`/serialization-context API with `JSON.json` +# and `JSONStyle`, so the two versions need different spellings of the same +# thing. `JSONContext` and `JSONSerialization` are aliases for JSON.jl < 1 +# internals that have no v1 equivalent, so they are defined only on that branch. +@static if isdefined(JSON, :JSONStyle) # JSON.jl >= 1 + struct JSEvalSerialization <: JSON.JSONStyle end + + # A `JSONText` is written to the output verbatim. Note that this means + # `verbose_json[]` has no effect here: there is no v1 equivalent of + # re-indenting a raw fragment across lines. + JSON.lower(::JSEvalSerialization, x::JSString) = JSON.JSONText(x.s) +else + const JSONContext = JSON.Writer.StructuralContext + const JSONSerialization = JSON.Serializations.CommonSerialization + + struct JSEvalSerialization <: JSONSerialization end + + # adapted (very slightly) from JSON.jl test/serializer.jl + function JSON.show_json(io::JSONContext, ::JSEvalSerialization, x::JSString) + if verbose_json[] + first = true + for line in split(x.s, '\n') + !first && JSON.indent(io) + first = false + Base.print(io, line) + end + else + Base.print(io, x.s) end - else - Base.print(io, x.s) end end diff --git a/test/syntax.jl b/test/syntax.jl index 8e1cffc8..bcb4e790 100644 --- a/test/syntax.jl +++ b/test/syntax.jl @@ -1,3 +1,4 @@ +using JSON using Test using WebIO @@ -41,4 +42,16 @@ using WebIO myvalue = js"a + 1" @test js"x = $myvalue".s == "x = a + 1" end + + @testset "JSStrings nested in interpolated values" begin + handler = Dict("onclick" => js"alert(1)") + + # Interpolating into a `js"..."` literal splices JSStrings in as raw + # JavaScript, at any depth. + @test js"f($handler)".s == "f({\"onclick\":alert(1)})" + + # The default JSON serialization instead quotes them, which is what + # keeps node props such as event handlers valid JSON. + @test JSON.json(handler) == "{\"onclick\":\"alert(1)\"}" + end end From 4e6948d196aa9f106fcd3caa70cafefbdcdcc186 Mon Sep 17 00:00:00 2001 From: fromelicks <108532072+fromelicks@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:03:50 +0200 Subject: [PATCH 2/4] fix: widen handle_rpc_request type input `request` is annotated `AbstractDict` rather than `Dict` because it comes straight off the wire: JSON.jl v1 materializes objects as `JSON.Object`, which is an `AbstractDict` but not a `Dict`. A `Dict` annotation in `handle_rpc_request` fails to match, and `dispatch_request` turns the resulting MethodError into an error response, so the RPC silently never runs. --- src/rpc.jl | 7 ++++++- test/communication.jl | 28 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/rpc.jl b/src/rpc.jl index c6b6c86c..f386361b 100644 --- a/src/rpc.jl +++ b/src/rpc.jl @@ -15,7 +15,12 @@ WebIO-internal method to handle a request to invoke an RPC from the browser. Looks up the requested RPC from the `registered_rpcs` dict and invokes the function using the provided arguments and returns the result. """ -function handle_rpc_request(request::Dict) +# `request` is annotated `AbstractDict` rather than `Dict` because it comes +# straight off the wire: JSON.jl v1 materializes objects as `JSON.Object`, which +# is an `AbstractDict` but not a `Dict`. A `Dict` annotation here fails to match, +# and `dispatch_request` turns the resulting MethodError into an error response, +# so the RPC silently never runs. +function handle_rpc_request(request::AbstractDict) rpc_id = get(request, "rpcId", nothing) rpc_hash = try parse(UInt, rpc_id) catch nothing end rpc = get(registered_rpcs, rpc_hash, nothing) diff --git a/test/communication.jl b/test/communication.jl index d9c1396a..379fd62b 100644 --- a/test/communication.jl +++ b/test/communication.jl @@ -120,3 +120,31 @@ import WebIO: dispatch # end # # end + +@testset "dispatching parsed messages" begin + # Messages reach `dispatch` as whatever the JSON parser produced. On + # JSON.jl v1 that is a `JSON.Object`, which is an `AbstractDict` but not a + # `Dict`, so a handler annotated `::Dict` fails to match and + # `dispatch_request` reports the MethodError back to the browser instead of + # running the RPC. Parse the message here rather than building a `Dict`, so + # the argument type is the one that actually comes off the wire. + conn = TestConn(Channel{Any}(1)) + WebIO.tojs(split) # register `split` as an RPC + + request = WebIO.JSON.parse(""" + { + "type": "request", + "request": "rpc", + "requestId": "parsed", + "rpcId": "$(hash(split))", + "arguments": ["foo bar"] + } + """) + dispatch(conn, request) + + response = take!(conn.channel) + @test response["requestId"] == "parsed" + @test !haskey(response, "error") # the handler itself failed to run + @test !haskey(response, "exception") # the RPC ran but threw + @test response["result"] == ["foo", "bar"] +end From 0acbd113e3cdd6437d7ed141d599dad7b884c1ee Mon Sep 17 00:00:00 2001 From: fromelicks <108532072+fromelicks@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:12:30 +0200 Subject: [PATCH 3/4] Write non-finite floats as null in `showjs` on JSON.jl v1 Under JSON.jl < 1, `JSEvalSerialization <: CommonSerialization` wrote `NaN`, `Inf` and `-Inf` as `null`. JSON.jl v1 instead throws an ArgumentError, so interpolating e.g. plot data with `NaN` gaps into a `js"..."` literal failed. Lowered non-finite floats to `nothing` under `JSEvalSerialization` to keep the JSON.jl < 1 output. --- src/syntax.jl | 4 ++++ test/syntax.jl | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/src/syntax.jl b/src/syntax.jl index 0ac93469..4cc9cc8b 100644 --- a/src/syntax.jl +++ b/src/syntax.jl @@ -252,6 +252,10 @@ const verbose_json = Ref(false) # `verbose_json[]` has no effect here: there is no v1 equivalent of # re-indenting a raw fragment across lines. JSON.lower(::JSEvalSerialization, x::JSString) = JSON.JSONText(x.s) + + # Non-finite floats are written as `null` as JSON.jl < 1 does, where + # JSON.jl v1 would throw instead. + JSON.lower(::JSEvalSerialization, x::AbstractFloat) = isfinite(x) ? x : nothing else const JSONContext = JSON.Writer.StructuralContext const JSONSerialization = JSON.Serializations.CommonSerialization diff --git a/test/syntax.jl b/test/syntax.jl index bcb4e790..f312d78b 100644 --- a/test/syntax.jl +++ b/test/syntax.jl @@ -54,4 +54,13 @@ using WebIO # keeps node props such as event handlers valid JSON. @test JSON.json(handler) == "{\"onclick\":\"alert(1)\"}" end + + @testset "Non-finite floats interpolate as null" begin + # JSON.jl v1 refuses to write NaN/Inf; interpolation into a `js"..."` + # literal writes them as `null`, as JSON.jl < 1 does. + data = [1.5, NaN, Inf, -Inf] + @test js"$data".s == "[1.5,null,null,null]" + trace = Dict("y" => data) + @test js"f($trace)".s == "f({\"y\":[1.5,null,null,null]})" + end end From 81de08f8ce6d7408821439ef15443fe2afd5aa03 Mon Sep 17 00:00:00 2001 From: fromelicks <108532072+fromelicks@users.noreply.github.com> Date: Tue, 29 Sep 2026 00:30:44 +0200 Subject: [PATCH 4/4] Write non-finite floats as null in all WebIO JSON output on JSON.jl v1 Under JSON.jl < 1, `JSON.json` and `JSON.print` wrote `NaN`, `Inf` and `-Inf` as `null`. JSON.jl v1 instead throws an `ArgumentError`, so a `NaN` in an Observable value or a node prop broke messages sent over the Mux and generic HTTP providers as well as rendering nodes and scopes. Routed these through a new `jsonstring`, which lowers non-finite floats to `nothing` under JSON.jl v1, like `JSEvalSerialization` does for `js"..."` interpolation. --- src/WebIO.jl | 1 + src/jsoncompat.jl | 17 +++++++++++++++++ src/providers/generic_http.jl | 2 +- src/providers/mux.jl | 2 +- src/render.jl | 2 +- src/scope.jl | 2 +- src/util.jl | 2 +- test/util-tests.jl | 15 +++++++++++++++ 8 files changed, 38 insertions(+), 5 deletions(-) create mode 100644 src/jsoncompat.jl diff --git a/src/WebIO.jl b/src/WebIO.jl index 35fd2441..19d74e2c 100644 --- a/src/WebIO.jl +++ b/src/WebIO.jl @@ -35,6 +35,7 @@ Base.Multimedia.istextmime(::WEBIO_NODE_MIME) = true const WEBIO_APPLICATION_MIME = MIME"application/vnd.webio.application+html" Base.Multimedia.istextmime(::WEBIO_APPLICATION_MIME) = true +include("jsoncompat.jl") include("util.jl") include("connection.jl") include("syntax.jl") diff --git a/src/jsoncompat.jl b/src/jsoncompat.jl new file mode 100644 index 00000000..334cd492 --- /dev/null +++ b/src/jsoncompat.jl @@ -0,0 +1,17 @@ +using JSON + +# JSON.jl compatibility +# +# JSON.jl < 1 writes non-finite floats as `null`, whereas JSON.jl v1 throws on +# them by default. `jsonstring` keeps the JSON.jl < 1 behavior for everything +# WebIO serializes for the frontend (messages, rendered nodes and scopes). See +# also `JSEvalSerialization` in `syntax.jl`, which does the same for `js"..."` +# interpolation. +@static if isdefined(JSON, :JSONStyle) # JSON.jl >= 1 + struct WebIOSerialization <: JSON.JSONStyle end + JSON.lower(::WebIOSerialization, x::AbstractFloat) = isfinite(x) ? x : nothing + + jsonstring(x) = JSON.json(x; style=WebIOSerialization()) +else + jsonstring(x) = JSON.json(x) +end diff --git a/src/providers/generic_http.jl b/src/providers/generic_http.jl index def98ed9..29436467 100644 --- a/src/providers/generic_http.jl +++ b/src/providers/generic_http.jl @@ -9,7 +9,7 @@ struct WSConnection{T} <: WebIO.AbstractConnection sock::T end -Sockets.send(p::WSConnection, data) = writeguarded(p.sock, JSON.json(data)) +Sockets.send(p::WSConnection, data) = writeguarded(p.sock, WebIO.jsonstring(data)) Base.isopen(p::WSConnection) = isopen(p.sock) if !isfile(GENERIC_HTTP_BUNDLE_PATH) diff --git a/src/providers/mux.jl b/src/providers/mux.jl index eaa9e90e..b6b055f0 100644 --- a/src/providers/mux.jl +++ b/src/providers/mux.jl @@ -48,7 +48,7 @@ function create_socket(req) end function Sockets.send(p::WebSockConnection, data) - write(p.sock, sprint(io->JSON.print(io,data))) + write(p.sock, WebIO.jsonstring(data)) end Base.isopen(p::WebSockConnection) = isopen(p.sock) diff --git a/src/render.jl b/src/render.jl index 08d9f349..d1b38d30 100644 --- a/src/render.jl +++ b/src/render.jl @@ -324,7 +324,7 @@ function register_renderable(::Type{T}) where T end function Base.show(io::IO, m::WEBIO_NODE_MIME, node::Node) - write(io, JSON.json(node)) + write(io, jsonstring(node)) end function Base.show(io::IO, m::MIME"text/html", x::Observable) diff --git a/src/scope.jl b/src/scope.jl index 9700673d..4820442d 100644 --- a/src/scope.jl +++ b/src/scope.jl @@ -397,7 +397,7 @@ function onjs(ob::AbstractObservable, f) end function Base.show(io::IO, ::WEBIO_NODE_MIME, x::Scope) - write(io, JSON.json(render(x))) + write(io, jsonstring(render(x))) end function Base.show(io::IO, m::MIME"text/html", x::Scope) show(io, m, render(x)) diff --git a/src/util.jl b/src/util.jl index d63a64a4..97f3af74 100644 --- a/src/util.jl +++ b/src/util.jl @@ -56,4 +56,4 @@ function escape_json(s::String) return replace(s, "/" => "\\/") end -escape_json(x::Any) = escape_json(JSON.json(x)) +escape_json(x::Any) = escape_json(jsonstring(x)) diff --git a/test/util-tests.jl b/test/util-tests.jl index a1f1ca40..f1c48548 100644 --- a/test/util-tests.jl +++ b/test/util-tests.jl @@ -18,3 +18,18 @@ using WebIO: kebab2camel, camel2kebab @test camelstrs[i] == kebabstrs[i] || camelstrs[i] == kebab_also_trues[i] end end + +@testset "Non-finite floats serialize as null" begin + # JSON.jl v1 refuses to write NaN/Inf; WebIO writes them as `null`, as + # JSON.jl < 1 does. + data = [1.5, NaN, Inf, -Inf] + @test WebIO.jsonstring(data) == "[1.5,null,null,null]" + @test WebIO.escape_json(Dict("x" => data)) == "{\"x\":[1.5,null,null,null]}" + + n = node(:div, style=Dict(:width => NaN)) + @test occursin("\"width\":null", sprint(show, WebIO.WEBIO_NODE_MIME(), n)) + + s = Scope() + s["obs"] = Observable(NaN) + @test occursin("null", sprint(show, WebIO.WEBIO_NODE_MIME(), s)) +end