diff --git a/.changeset/calm-widgets-render.md b/.changeset/calm-widgets-render.md new file mode 100644 index 0000000..bd29d0b --- /dev/null +++ b/.changeset/calm-widgets-render.md @@ -0,0 +1,5 @@ +--- +"@playfast/echoform": patch +--- + +Keep client transports active across React Strict Mode lifecycle probes and deliver synchronously replayed initial frames. diff --git a/.changeset/client-view-tree-lifecycle.md b/.changeset/client-view-tree-lifecycle.md new file mode 100644 index 0000000..b891996 --- /dev/null +++ b/.changeset/client-view-tree-lifecycle.md @@ -0,0 +1,5 @@ +--- +"@playfast/echoform": minor +--- + +Expose client view-tree lifecycle updates so hosts can distinguish empty and pending renders. diff --git a/bun.lock b/bun.lock index 9b6c5cb..886a297 100644 --- a/bun.lock +++ b/bun.lock @@ -15,7 +15,7 @@ }, "demo/dev-server": { "name": "@demo/dev-server", - "version": "1.0.13", + "version": "1.0.14", "dependencies": { "@playfast/wmux": "workspace:*", "@playfast/wmux-client-terminal": "workspace:*", @@ -132,9 +132,12 @@ "typed-binary": "^4.3.3", }, "devDependencies": { + "@happy-dom/global-registrator": "20.10.6", "@playfast/echoform-render": "workspace:*", "@types/react": "19.2.17", + "@types/react-dom": "19.2.3", "react": "19.2.7", + "react-dom": "19.2.7", }, "peerDependencies": { "react": ">=19.0.0", @@ -159,7 +162,7 @@ }, "packages/wmux": { "name": "@playfast/wmux", - "version": "0.2.12", + "version": "0.3.0", "dependencies": { "@playfast/echoform": "workspace:*", "@playfast/echoform-render": "workspace:*", @@ -176,7 +179,7 @@ }, "packages/wmux-client": { "name": "@playfast/wmux-client", - "version": "0.0.14", + "version": "0.0.15", "dependencies": { "@dnd-kit/react": "^0.3.2", "@heroui/react": "^3.0.0", @@ -205,7 +208,7 @@ }, "packages/wmux-client-terminal": { "name": "@playfast/wmux-client-terminal", - "version": "0.0.6", + "version": "0.0.7", "dependencies": { "@opentui/core": "0.4.1", "@opentui/react": "0.4.1", @@ -445,6 +448,8 @@ "@formatjs/intl-localematcher": ["@formatjs/intl-localematcher@0.6.2", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA=="], + "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.10.6", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.10.6" } }, "sha512-Nu/IjRkkNxmeG2ywWsyJSO4d1BrWTqVzxCPL+gXj0b97klhmjd6wLzt6Bx/laNpkZ3WLW7zNCqmtMIbIlahqug=="], + "@heroui/react": ["@heroui/react@3.0.1", "", { "dependencies": { "@heroui/styles": "3.0.1", "@radix-ui/react-avatar": "1.1.11", "@react-aria/i18n": "3.12.16", "@react-aria/ssr": "3.9.10", "@react-aria/utils": "3.33.1", "@react-stately/utils": "3.11.0", "@react-types/color": "3.1.4", "@react-types/shared": "3.33.1", "input-otp": "1.4.2", "react-aria-components": "1.16.0", "tailwind-merge": "3.4.0", "tailwind-variants": "3.2.2" }, "peerDependencies": { "react": ">=19.0.0", "react-dom": ">=19.0.0", "tailwindcss": ">=4.0.0" } }, "sha512-Dx5oku2LPgK1+Uy1KIyVcfqgYTkVCXEII9OuwoKIJe7GW9Lf1xdHpSTwsdDZoNrUl8IvkDjav1ud92OGY/maRA=="], "@heroui/styles": ["@heroui/styles@3.0.1", "", { "dependencies": { "tailwind-variants": "3.2.2", "tw-animate-css": "1.4.0" }, "peerDependencies": { "tailwindcss": ">=4.0.0" } }, "sha512-ZGXz6nse8713f3uZMNapa8STkiyERgE7P5qOJlWTGnO8qHC+OHCB9TRJOdz1JR+daAhxu68pBO1qdWlzvud1cw=="], @@ -953,6 +958,8 @@ "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], "@typescript/native-preview": ["@typescript/native-preview@7.0.0-dev.20260323.1", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260323.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20260323.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20260323.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20260323.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20260323.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20260323.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20260323.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-e8rnqL5I4DUSjiy6jiWqYFKcgKq8tC4S+uEmJwWiyTgSIGDhaRUujJ2pb6EXmi+NPeXoES6vIG7e9BsEV0WSow=="], @@ -1003,6 +1010,8 @@ "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], + "bufferutil": ["bufferutil@4.0.7", "", { "dependencies": { "node-gyp-build": "^4.3.0" } }, "sha512-kukuqc39WOHtdxtw4UScxF/WVnMFVSQVKhtx3AjZJzhd0RGZZldcrfSEbVsWWe6KNH253574cq5F+wpv0G9pJw=="], "bun-ffi-structs": ["bun-ffi-structs@0.2.3", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-pgJiXP+hEgFo9qG51J6ItfY4ocs3vniwNzJ9WhoakB3QB2GdzQxX2EXssentPYlB2hOfJrTjO6iIQkWYzUodpg=="], @@ -1073,6 +1082,8 @@ "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -1117,6 +1128,8 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "happy-dom": ["happy-dom@20.10.6", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-6QD0ilzDDt93tX44y8tbmZdAcdTRYDhUP+Asgi6pC8Pp5IA3cvaZGyoVN/EGtlq9ziT65iPuBBn3ASLr6hCgVw=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "human-id": ["human-id@4.1.3", "", { "bin": { "human-id": "dist/cli.js" } }, "sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q=="], @@ -1375,6 +1388,8 @@ "typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="], + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + "universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], @@ -1395,11 +1410,13 @@ "web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], - "ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], "xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="], @@ -1427,6 +1444,8 @@ "@demo/widget-plugins/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "@happy-dom/global-registrator/@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], + "@manypkg/find-root/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], "@manypkg/get-packages/@changesets/types": ["@changesets/types@4.1.0", "", {}, "sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw=="], @@ -1469,6 +1488,8 @@ "@types/ws/@types/node": ["@types/node@18.11.9", "", {}, "sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg=="], + "buffer-image-size/@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], + "bun-types/@types/node": ["@types/node@18.11.9", "", {}, "sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg=="], "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -1479,12 +1500,18 @@ "engine.io/@types/node": ["@types/node@18.11.9", "", {}, "sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg=="], + "engine.io/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + + "engine.io-client/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + "enquirer/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "file-editor-demo/typescript": ["typescript@5.7.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg=="], "file-editor-demo/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "happy-dom/@types/node": ["@types/node@26.1.1", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw=="], + "knip/@types/node": ["@types/node@18.11.9", "", {}, "sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg=="], "knip/zod": ["zod@4.2.1", "", {}, "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw=="], @@ -1499,6 +1526,8 @@ "read-yaml-file/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], + "socket.io-adapter/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + "socket.io-parser/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], "todo-app-demo/typescript": ["typescript@5.7.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg=="], diff --git a/packages/echoform/package.json b/packages/echoform/package.json index c963ccd..7fc5a15 100644 --- a/packages/echoform/package.json +++ b/packages/echoform/package.json @@ -42,9 +42,12 @@ "typed-binary": "^4.3.3" }, "devDependencies": { + "@happy-dom/global-registrator": "20.10.6", "@playfast/echoform-render": "workspace:*", "@types/react": "19.2.17", - "react": "19.2.7" + "@types/react-dom": "19.2.3", + "react": "19.2.7", + "react-dom": "19.2.7" }, "keywords": [ "react", diff --git a/packages/echoform/src/client/Client.tsx b/packages/echoform/src/client/Client.tsx index 42ae8d0..9eb93e2 100644 --- a/packages/echoform/src/client/Client.tsx +++ b/packages/echoform/src/client/Client.tsx @@ -7,7 +7,10 @@ import type { } from "../shared/types"; import type { EventUid, StreamUid } from "../shared/branded.types"; import { createRequestUid } from "../shared/branded.types"; -import { decompileTransport } from "../shared/decompiled-transport"; +import { + decompileTransport, + type DecompileTransport, +} from "../shared/decompiled-transport"; import { randomId } from "../shared/id"; import { ViewsRenderer } from "../shared/ViewsRenderer"; import { stringifyWithoutCircular } from "../shared/serialization.utils"; @@ -50,27 +53,51 @@ function applyViewDeletion( return [...state.slice(0, runningViewIndex), ...state.slice(runningViewIndex + 1)]; } -interface ClientProps = Record> { +export interface ClientViewsState { + readonly views: ReadonlyArray; + readonly hasReceivedViewTree: boolean; +} + +interface ClientPropsExternalApi = Record> { readonly transport: Transport; // eslint-disable-next-line @typescript-eslint/no-explicit-any -- props are built dynamically by ViewsRenderer readonly views: Readonly>>; readonly requestViewTreeOnMount?: boolean; + readonly onViewsChange?: (state: ClientViewsState) => void; } function Client = Record>({ transport: rawTransport, views, requestViewTreeOnMount = true, -}: ClientProps): React.ReactElement { + onViewsChange, +}: ClientPropsExternalApi): React.ReactElement { const [runningViews, setRunningViews] = useState>([]); - const transportRef = useRef(decompileTransport(rawTransport)); + const runningViewsRef = useRef>([]); + const transportRef = useRef(undefined); + const hasReceivedViewTreeRef = useRef(false); + const onViewsChangeRef = useRef(onViewsChange); const streamListenersRef = useRef void>>>(new Map()); const pendingReplayRef = useRef>>(new Map()); + onViewsChangeRef.current = onViewsChange; + + const updateRunningViews = useCallback((nextViews: ReadonlyArray): void => { + runningViewsRef.current = nextViews; + setRunningViews(nextViews); + onViewsChangeRef.current?.({ + views: nextViews, + hasReceivedViewTree: hasReceivedViewTreeRef.current, + }); + }, []); const createEvent = useCallback((eventUid: EventUid, ...args: ReadonlyArray): Promise => { return new Promise((resolve, reject) => { const requestUid = createRequestUid(randomId()); const transport = transportRef.current; + if (transport === undefined) { + reject(new Error("echoform: client transport is not active")); + return; + } let unsubscribe: (() => void) | undefined; @@ -133,18 +160,22 @@ function Client = Record { - const transport = transportRef.current; + const transport = decompileTransport(rawTransport); + transportRef.current = transport; + hasReceivedViewTreeRef.current = false; + updateRunningViews([]); const updateViewsTreeHandler = ({ views }: AppEvents['update_views_tree']): void => { - setRunningViews(views); + hasReceivedViewTreeRef.current = true; + updateRunningViews(views); }; const updateViewHandler = ({ view }: AppEvents['update_view']): void => { - setRunningViews((state) => applyViewUpdate(state, view)); + updateRunningViews(applyViewUpdate(runningViewsRef.current, view)); }; const deleteViewHandler = ({ viewUid }: AppEvents['delete_view']): void => { - setRunningViews((state) => applyViewDeletion(state, viewUid)); + updateRunningViews(applyViewDeletion(runningViewsRef.current, viewUid)); }; const streamChunkHandler = ({ streamUid, chunk }: AppEvents['stream_chunk']): void => { @@ -190,6 +221,9 @@ function Client = Record { + if (transportRef.current === transport) { + transportRef.current = undefined; + } unsubscribeViewsTree?.(); unsubscribeUpdateView?.(); unsubscribeDeleteView?.(); @@ -198,9 +232,11 @@ function Client = Record void ): (() => void) | undefined => { if (destroyed) return undefined; - ensureRawListener(); const eventName = event as string; let handlerSet = appHandlers.get(eventName); @@ -75,6 +74,7 @@ export function decompileTransport void); + ensureRawListener(); return () => { const set = appHandlers.get(eventName); diff --git a/packages/echoform/tests/client-strict-mode.test.tsx b/packages/echoform/tests/client-strict-mode.test.tsx new file mode 100644 index 0000000..7b94341 --- /dev/null +++ b/packages/echoform/tests/client-strict-mode.test.tsx @@ -0,0 +1,152 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; +import React, { StrictMode, act } from "react"; +import { Client, type ClientViewsState } from "../src/client"; +import { decodeMessage, encodeMessage } from "../src/shared/binary-protocol"; +import type { Transport } from "../src/shared/types"; + +interface BinaryEvents { + readonly __bin__: Uint8Array; +} + +interface StatusProps { + readonly label: string; +} + +interface TestTransportHarness { + readonly transport: Transport; + readonly listenerCount: () => number; + readonly dispatch: (frame: Uint8Array) => void; +} + +const READY_VIEW_TREE = { + views: [ + { + uid: "strict-mode-status", + name: "Status", + parentUid: "", + childIndex: 0, + isRoot: true, + props: [{ name: "label", type: "data", data: "ready" }], + }, + ], +} as const; + +function Status({ label }: StatusProps): React.ReactElement { + return {label}; +} + +function dispatchViewTree( + listeners: ReadonlySet<(data: Uint8Array) => void>, +): void { + const frame = encodeMessage("update_views_tree", READY_VIEW_TREE); + listeners.forEach((listener) => listener(frame)); +} + +function createTestTransport(): TestTransportHarness { + const listeners = new Set<(data: Uint8Array) => void>(); + const dispatch = (frame: Uint8Array): void => { + listeners.forEach((listener) => listener(frame)); + }; + + return { + transport: { + emit: (_event, message) => { + if (message === undefined) return; + if (decodeMessage(message).event !== "request_views_tree") return; + queueMicrotask(() => dispatch(encodeMessage("update_views_tree", READY_VIEW_TREE))); + }, + on: (_event, handler) => { + listeners.add(handler); + }, + off: (_event, handler) => { + listeners.delete(handler); + }, + }, + listenerCount: () => listeners.size, + dispatch, + }; +} + +function waitForMicrotask(): Promise { + return new Promise((resolve) => queueMicrotask(resolve)); +} + +beforeAll(() => { + GlobalRegistrator.register(); + Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { + configurable: true, + value: true, + }); +}); + +afterAll(() => { + Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT"); + GlobalRegistrator.unregister(); +}); + +describe("Client", () => { + test("renders after the StrictMode effect lifecycle probe", async () => { + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + const root = createRoot(container); + const transportHarness = createTestTransport(); + + await act(async () => { + root.render( + + + , + ); + }); + await act(async () => { + await waitForMicrotask(); + }); + + expect(container.textContent).toBe("ready"); + expect(transportHarness.listenerCount()).toBe(1); + + await act(async () => root.unmount()); + expect(transportHarness.listenerCount()).toBe(0); + }); + + test("reports an empty snapshot before a widget gains renderable views", async () => { + const { createRoot } = await import("react-dom/client"); + const container = document.createElement("div"); + const root = createRoot(container); + const transportHarness = createTestTransport(); + const states = new Set(); + + await act(async () => { + root.render( + states.add(state)} + />, + ); + }); + await act(async () => { + transportHarness.dispatch(encodeMessage("update_views_tree", { views: [] })); + }); + + expect([...states].at(-1)).toEqual({ views: [], hasReceivedViewTree: true }); + expect(container.textContent).toBe(""); + + await act(async () => { + transportHarness.dispatch(encodeMessage("update_views_tree", READY_VIEW_TREE)); + }); + + expect([...states].at(-1)).toEqual({ + views: READY_VIEW_TREE.views, + hasReceivedViewTree: true, + }); + expect(container.textContent).toBe("ready"); + + await act(async () => root.unmount()); + }); +}); diff --git a/packages/echoform/tests/decompiled-transport.test.ts b/packages/echoform/tests/decompiled-transport.test.ts new file mode 100644 index 0000000..89bc6c8 --- /dev/null +++ b/packages/echoform/tests/decompiled-transport.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, mock, test } from "bun:test"; +import { encodeMessage } from "../src/shared/binary-protocol"; +import { decompileTransport } from "../src/shared/decompiled-transport"; +import type { Transport } from "../src/shared/types"; + +interface BinaryEvents { + readonly __bin__: Uint8Array; +} + +const VIEW_TREE_FRAME = encodeMessage("update_views_tree", { + views: [ + { + uid: "replayed-status", + name: "Status", + parentUid: "", + childIndex: 0, + isRoot: true, + props: [{ name: "label", type: "data", data: "ready" }], + }, + ], +}); + +function createSynchronouslyReplayingTransport(): Transport { + return { + emit: () => undefined, + on: (_event, handler) => handler(VIEW_TREE_FRAME), + off: () => undefined, + }; +} + +describe("decompileTransport", () => { + test("delivers a frame replayed while the raw listener attaches", () => { + const handler = mock(() => undefined); + const transport = decompileTransport(createSynchronouslyReplayingTransport()); + + transport.on("update_views_tree", handler); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler.mock.calls[0]?.[0].views[0]?.name).toBe("Status"); + }); +}); diff --git a/packages/wmux-client-terminal/src/components/LocalFileViewer.tsx b/packages/wmux-client-terminal/src/components/LocalFileViewer.tsx index 309f74e..5ade1a4 100644 --- a/packages/wmux-client-terminal/src/components/LocalFileViewer.tsx +++ b/packages/wmux-client-terminal/src/components/LocalFileViewer.tsx @@ -16,7 +16,7 @@ export const LocalFileViewer = ({ filePath }: LocalFileViewerProps): ReactNode = setContent(null); setError(null); readFile(filePath, "utf-8") - .then((text) => { if (!cancelled) setContent(text); }) + .then((text) => { if (!cancelled) setContent(text.toString()); }) .catch((err) => { if (!cancelled) setError(String(err)); }); return () => { cancelled = true; }; }, [filePath]);