From 08518ea7f75359117d8524703457846aae8c3210 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Tue, 13 Oct 2026 09:34:48 -0700 Subject: [PATCH] Fix XY namespace activation with Reflex 0.10 --- news/526.bugfix.md | 1 + package-lock.json | 301 +++++++++++++++++++++++++ package.json | 2 + python/reflex_xy/assets/XYChart.jsx | 5 + spec/design/reflex-integration.md | 13 ++ tests/reflex_socket_connection.cjs | 168 ++++++++++++++ tests/test_reflex_socket_connection.py | 29 +++ 7 files changed, 519 insertions(+) create mode 100644 news/526.bugfix.md create mode 100644 tests/reflex_socket_connection.cjs create mode 100644 tests/test_reflex_socket_connection.py diff --git a/news/526.bugfix.md b/news/526.bugfix.md new file mode 100644 index 00000000..afd7cc8e --- /dev/null +++ b/news/526.bugfix.md @@ -0,0 +1 @@ +Connect the XY chart namespace explicitly when Reflex 0.10 pre-creates the shared Socket.IO manager with auto-connect disabled, restoring live chart rendering. diff --git a/package-lock.json b/package-lock.json index 2ea1ddad..482c05c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,8 @@ "name": "xy-dev-tools", "devDependencies": { "playwright": "1.61.1", + "socket.io": "4.8.4", + "socket.io-client": "4.8.4", "typescript": "^7.0.2", "vite": "^8.1.5" } @@ -356,6 +358,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "dev": true, + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -367,6 +376,36 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "26.6.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript/typescript-aix-ppc64": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", @@ -707,6 +746,66 @@ "node": ">=16.20.0" } }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -717,6 +816,51 @@ "node": ">=8" } }, + "node_modules/engine.io": { + "version": "6.6.11", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.11.tgz", + "integrity": "sha512-z34mGpLoY/82ZbkX1LqGMIaf3wjVMPL6gVC681QDqnmzJ26synI8qAE32P+a7UazmpJZ8UGrFIeAavgRfXzYrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "@types/ws": "^8.5.12", + "accepts": "~1.3.4", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-client": { + "version": "6.6.7", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.7.tgz", + "integrity": "sha512-GyAvmPA7gnRCpDQbYACAy79Lg4ewevrmaPzySZz0OVvgyZc3gEfLLqPTYk8wVBScjOEpM9ZO4KryyFpVItjGEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1023,6 +1167,36 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.16", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", @@ -1042,6 +1216,26 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1157,6 +1351,65 @@ "@rolldown/binding-win32-x64-msvc": "1.1.5" } }, + "node_modules/socket.io": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.4.tgz", + "integrity": "sha512-9ZV9zg8vN8hO3Y25DhR7nUkJ1VDnBhq5O/JrXhFT9s6+F0gqytSASTHVfCFc2mJXaTfARroErplXlzFWwETZqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz", + "integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.21.0" + } + }, + "node_modules/socket.io-client": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.4.tgz", + "integrity": "sha512-7wjKAvVXYOJsbmUgkWbnhPD4bFpkqrUR0++XOjpGFmqDqsqV1CI93VTraeExLxpjxEqrWgFoSRZu1ihjLWfkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz", + "integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1227,6 +1480,23 @@ "@typescript/typescript-win32-x64": "7.0.2" } }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vite": { "version": "8.1.5", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", @@ -1319,6 +1589,37 @@ "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } } } } diff --git a/package.json b/package.json index c5595af9..1f87ce09 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ }, "devDependencies": { "playwright": "1.61.1", + "socket.io": "4.8.4", + "socket.io-client": "4.8.4", "typescript": "^7.0.2", "vite": "^8.1.5" }, diff --git a/python/reflex_xy/assets/XYChart.jsx b/python/reflex_xy/assets/XYChart.jsx index 9bab7c54..30750095 100644 --- a/python/reflex_xy/assets/XYChart.jsx +++ b/python/reflex_xy/assets/XYChart.jsx @@ -83,6 +83,11 @@ function xySocket() { }); sharedSocket.on("connect", () => dbg("xy namespace connected")); sharedSocket.on("connect_error", (e) => dbg("xy connect_error", String(e))); + // Reflex 0.10 pre-creates the shared manager with autoConnect: false. + // Activate this namespace explicitly; io() inherits the manager's setting + // even when the app plane has already opened its transport. An active + // legacy socket may already have its CONNECT packet in flight. + if (!sharedSocket.active) sharedSocket.connect(); return sharedSocket; } diff --git a/spec/design/reflex-integration.md b/spec/design/reflex-integration.md index c18d2bfe..31c5349c 100644 --- a/spec/design/reflex-integration.md +++ b/spec/design/reflex-integration.md @@ -105,6 +105,19 @@ network tab, two namespaces inside it. React effect ordering means the chart often connects first; mirroring the options exactly is what makes that safe (the backend sees an identical connection either way). +The wrapper explicitly calls `connect()` on the newly created namespace if it +is not already active, after installing its diagnostic listeners. It must not rely on the manager's +`autoConnect`: Reflex 0.10 creates a warm manager with `autoConnect: false`, +and namespace construction inherits that setting even after its transport is +open. The public `active` guard avoids sending a second CONNECT while an older +auto-connecting manager already has the namespace handshake in flight. It +also preserves XY-first creation and does not change the manager options. +The cached-socket path does not call `connect()` again, so mounting another +chart during an app-managed disconnect cannot reopen the transport early. +`tests/test_reflex_socket_connection.py` exercises the shipped wrapper with +real Socket.IO peers for both creation orders, warm/open/legacy managers, +binary payload dispatch, repeated mounts, cleanup, and app-owned reconnects. + Reflex owns reconnection: its `reconnect()` reopens the shared manager, our namespace socket re-CONNECTs automatically, and every mounted chart re-`sub`s on the `connect` event. diff --git a/tests/reflex_socket_connection.cjs b/tests/reflex_socket_connection.cjs new file mode 100644 index 00000000..1b344950 --- /dev/null +++ b/tests/reflex_socket_connection.cjs @@ -0,0 +1,168 @@ +// Execute the shipped wrapper against real Socket.IO peers. Only React/DOM and +// the renderer are stubbed: this gate tests transport and render dispatch, not +// GPU pixels (covered by the browser smoke). Run via test_reflex_socket_connection.py. +const assert = require("node:assert/strict"); +const { readFileSync } = require("node:fs"); +const { createServer } = require("node:http"); +const { resolve } = require("node:path"); +const { setTimeout: delay } = require("node:timers/promises"); +const { test } = require("node:test"); +const { io } = require("socket.io-client"); +const { Server } = require("socket.io"); + +const wrapper = readFileSync(resolve(__dirname, "../python/reflex_xy/assets/XYChart.jsx"), "utf8") + .replace(/^import[\s\S]*?;\s*$/gm, "") + .replace("export function XYChart", "function XYChart"); + +async function until(check, label) { + const deadline = Date.now() + 3000; + while (!check()) { + assert.ok(Date.now() < deadline, `Timed out: ${label}`); + await delay(10); + } +} + +function loadWrapper(endpoint) { + let effects = []; + const renders = []; + const windows = {}; + class ChartView { + constructor(el, spec, buffers) { + this.spec = spec; + this.buffers = buffers; + renders.push(this); + } + destroy() { this.destroyed = true; } + } + function jsx(tag, props, ...children) { + const el = { + id: props.id, + clientWidth: 400, + replaceChildren() {}, + addEventListener() {}, + removeEventListener() {}, + }; + if (typeof props.ref === "function") props.ref(el); + else if (props.ref) props.ref.current = el; + return el; + } + const factory = new Function( + "io", "env", "reflexEnvironment", "getBackendURL", "getToken", + "useEffect", "useRef", "useState", "jsx", "ChartView", "window", + `${wrapper}\nreturn {XYChart, xySocket, getSocket: () => sharedSocket};`, + ); + const loaded = factory( + io, { EVENT: endpoint, TRANSPORT: "websocket" }, { version: "test" }, + (url) => new URL(url), () => "session-token", + (callback) => effects.push(callback), (current) => ({ current }), + (value) => [value, () => {}], jsx, ChartView, windows, + ); + return { + ...loaded, + renders, + mount(id, props = { figure: { token: "figure-token" } }) { + effects = []; + loaded.XYChart({ id, ...props }); + const cleanups = effects.map((effect) => effect()).filter(Boolean); + return () => cleanups.reverse().forEach((cleanup) => cleanup()); + }, + }; +} + +for (const order of ["warm-manager", "open-warm-manager", "legacy-auto-connect", "open-legacy-manager", "xy-first"]) { + test(`${order}: subscribe, dispatch payload, share transport, resubscribe`, async () => { + const http = createServer(); + const server = new Server(http, { path: "/_event", transports: ["websocket"] }); + server.of("/_event"); + let connections = 0; + let namespaceConnections = 0; + server.engine.on("connection", () => connections++); + const subs = []; + const unsubs = []; + server.of("/_xy").on("connection", (socket) => { + namespaceConnections++; + socket.on("sub", (request) => { + subs.push(request); + socket.emit("payload", { + ...request, version: 1, spec: { traces: [] }, + buffers: [Buffer.from(new Float32Array([1, 2]).buffer)], + }); + }); + socket.on("unsub", (request) => unsubs.push(request)); + }); + await new Promise((resolve) => http.listen(0, "127.0.0.1", resolve)); + const origin = `http://127.0.0.1:${http.address().port}`; + const loaded = loadWrapper(`${origin}/_event`); + let app; + const mounts = []; + const makeApp = () => io(`${origin}/_event`, { + path: "/_event", transports: ["websocket"], protocols: ["test"], + query: { token: "session-token" }, reconnection: false, + autoConnect: order === "legacy-auto-connect" || order === "open-legacy-manager", + }); + try { + if (order !== "xy-first") { + app = makeApp(); + if (order === "open-warm-manager" || order === "open-legacy-manager") { + app.connect(); + await until(() => app.connected, "app namespace connected first"); + } + } + mounts.push(loaded.mount("one")); + if (!app) app = makeApp(); + app.connect(); + await until(() => loaded.renders.length === 1 && app.connected, "live chart payload dispatched"); + const socket = loaded.getSocket(); + assert.equal(socket.io, app.io, "both namespaces must use the same manager"); + assert.equal(connections, 1, "one physical transport"); + assert.equal(namespaceConnections, 1, "one namespace CONNECT per transport"); + assert.deepEqual([...loaded.renders[0].buffers], [0, 0, 128, 63, 0, 0, 0, 64]); + assert.equal(subs.length, 1); + assert.equal(socket.io.reconnection(), false, "Reflex owns reconnects"); + assert.equal(socket.io._autoConnect, order === "legacy-auto-connect" || order === "open-legacy-manager" || order === "xy-first"); + + // A second chart uses the cached namespace without another connection. + mounts.push(loaded.mount("two")); + await until(() => loaded.renders.length === 2, "second mount payload"); + assert.equal(connections, 1); + assert.equal(subs.length, 2); + assert.notEqual(subs[0].mid, subs[1].mid); + + // Simulate the app plane closing and reopening its shared manager. + // A chart mounting during the pause must NOT reopen it on its own. + app.io.disconnect(); + await until(() => !socket.connected && !app.connected, "manager closed"); + mounts.push(loaded.mount("during-pause")); + await delay(30); + assert.equal(app.io._readyState, "closed"); + assert.equal(connections, 1); + app.connect(); + await until(() => loaded.renders.length === 5, "all mounts resubscribe after app reconnect"); + assert.equal(connections, 2); + assert.equal(namespaceConnections, 2); + assert.equal(subs.length, 5); + assert.equal(socket.io, app.io); + + mounts.pop()(); + mounts.pop()(); + await delay(30); + assert.equal(unsubs.length, 0, "same-token mounts retain the shared room"); + mounts.pop()(); + await until(() => unsubs.length === 1, "last mount unsubscribes"); + assert.equal(app.connected, true, "chart cleanup leaves the app transport alive"); + assert.equal(socket.connected, true); + } finally { + mounts.reverse().forEach((cleanup) => cleanup()); + loaded.getSocket()?.disconnect(); + app?.disconnect(); + await new Promise((resolve) => server.close(resolve)); + } + }); +} + +test("an unhydrated figure never creates a socket", () => { + const loaded = loadWrapper("http://127.0.0.1:1/_event"); + const unmount = loaded.mount("empty", { figure: { token: "" } }); + assert.equal(loaded.getSocket(), null); + unmount(); +}); diff --git a/tests/test_reflex_socket_connection.py b/tests/test_reflex_socket_connection.py new file mode 100644 index 00000000..24497593 --- /dev/null +++ b/tests/test_reflex_socket_connection.py @@ -0,0 +1,29 @@ +"""Real Socket.IO regression gate for Reflex's shared-manager connection order. + +Runs independently of the optional Python Reflex/native core dependencies. +Install the repo's pinned npm development dependencies with ``npm ci`` first. +""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] + + +def test_reflex_socket_connection(): + node = shutil.which("node") + if node is None: + pytest.skip("Node.js unavailable") + result = subprocess.run( + [node, "--test", "tests/reflex_socket_connection.cjs"], + cwd=ROOT, + capture_output=True, + text=True, + timeout=60, + ) + assert result.returncode == 0, result.stdout + result.stderr