Own the live socket outside React so leaving Home cannot orphan it
The socket belonged to whichever hook instance ran its effect first. Passive effects run children before parents, so on Home that was the brain graph rather than the shell: navigating to a sibling route unmounted the graph, which closed the socket, while the shell kept the reference count above zero. From there the page was deaf for the rest of its life, with nothing left to reconnect it. A module-level connection with a real refcount replaces it — connect on the first subscriber, disconnect on the last — and the hook is a thin subscription with the same signature, correct under StrictMode's mount/unmount/mount. A 1008 now reconnects instead of returning silently: the token is read afresh per attempt, and three consecutive rejections fall through to the caller's auth handler so a revoked session surfaces rather than spins. The snapshot's emit counts are read into a store of their own, apart from the live count, so a graph that connects into a busy engine is drawn as busy without every neuron claiming it just fired. The neuron and edge pulses now key off a change seen while they were mounted, so returning to Home no longer replays every emission of the session. Home gets a live indicator for the case none of this can fix: quiet while the socket is up, and named in words when it is down, since HTTP polling keeps the rest of the page looking current. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HTsT1isxUjw5gtkJk8WhuA
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { api, apiPage, deleteAll } from "./utils/api"
|
||||
|
||||
/**
|
||||
* One socket serves the whole shell, and it has to survive the routes under it
|
||||
* coming and going.
|
||||
*
|
||||
* It used to belong to whichever component's effect ran first, which on Home is
|
||||
* the brain graph rather than the shell around it. Leaving Home closed the
|
||||
* socket while the shell held the reference count above zero, and the page was
|
||||
* deaf for the rest of its life: no live values, no pulses, and nothing that
|
||||
* would ever reconnect.
|
||||
*/
|
||||
|
||||
const flowName = `test_live_${Date.now().toString(36)}`
|
||||
|
||||
test.use({ storageState: "playwright/.auth/user.json" })
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
const page = await apiPage(browser)
|
||||
await api(page, `/flows/${flowName}`, {
|
||||
method: "PUT",
|
||||
data: {
|
||||
name: flowName,
|
||||
title: "Live",
|
||||
nodes: [
|
||||
{
|
||||
id: "source",
|
||||
type: "python",
|
||||
provides: [{ name: "reading", dtype: "float" }],
|
||||
},
|
||||
{
|
||||
id: "sink",
|
||||
type: "python",
|
||||
requires: [{ name: "reading", dtype: "float" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
const detail = await (await api(page, `/flows/${flowName}`)).json()
|
||||
await api(page, `/flows/${flowName}/publish`, {
|
||||
method: "POST",
|
||||
data: { version: detail.definition.version },
|
||||
})
|
||||
await page.close()
|
||||
})
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
await deleteAll(browser, [`/flows/${flowName}`])
|
||||
})
|
||||
|
||||
test("the live socket survives leaving Home and coming back", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript(() => {
|
||||
const Native = window.WebSocket
|
||||
const opened: WebSocket[] = []
|
||||
;(window as unknown as { __sockets: WebSocket[] }).__sockets = opened
|
||||
window.WebSocket = class extends Native {
|
||||
constructor(url: string | URL, protocols?: string | string[]) {
|
||||
super(url, protocols)
|
||||
opened.push(this)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await page.goto("/")
|
||||
const neuron = page.locator(".brain-cell").first()
|
||||
await neuron.waitFor()
|
||||
|
||||
// The full-bleed canvas is its own shell, so a trip through it takes the
|
||||
// sidebar shell down to nothing: coming back mounts the shell and the brain
|
||||
// graph in the same commit, which is the order that used to pick an owner.
|
||||
await page.goto(`/flows/${flowName}`)
|
||||
const bar = page.locator('[data-sidebar="sidebar"]')
|
||||
await bar.getByRole("link", { name: "Home", exact: true }).click()
|
||||
await neuron.waitFor()
|
||||
|
||||
// And this is the navigation that used to end it: a sibling route in the
|
||||
// same shell, which unmounts the graph but not the shell.
|
||||
await bar.getByRole("link", { name: "Flows", exact: true }).click()
|
||||
await page.waitForURL(/\/flows$/)
|
||||
await bar.getByRole("link", { name: "Home", exact: true }).click()
|
||||
await neuron.waitFor()
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
(window as unknown as { __sockets: WebSocket[] }).__sockets.some(
|
||||
(socket) => socket.readyState === 1,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
Reference in New Issue
Block a user