diff --git a/.github/workflows/pr-validate.yaml b/.github/workflows/pr-validate.yaml index f85ee7d..ef4f584 100644 --- a/.github/workflows/pr-validate.yaml +++ b/.github/workflows/pr-validate.yaml @@ -48,6 +48,14 @@ jobs: - name: Verify packages run: node common/scripts/install-run-rush.js verify --parallelism 1 + - name: Install Relay lifecycle browser + working-directory: tools/webapp-graphql-relay-lifecycle-fixture + run: npx playwright install --with-deps chromium + + - name: Test Relay lifecycle browser + working-directory: tools/webapp-graphql-relay-lifecycle-fixture + run: npm run test:browser + - name: Test packed Relay consumer contract run: npm run test:relay-consumer-contract diff --git a/common/changes/@omgjs/labkit-webapp-graphql-relay/main_2026-08-11-16-28.json b/common/changes/@omgjs/labkit-webapp-graphql-relay/main_2026-08-11-16-28.json new file mode 100644 index 0000000..f6de6a6 --- /dev/null +++ b/common/changes/@omgjs/labkit-webapp-graphql-relay/main_2026-08-11-16-28.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@omgjs/labkit-webapp-graphql-relay", + "comment": "Add route-query lifetime ownership for mounted Relay consumers and document TanStack Router migration.", + "type": "minor" + } + ], + "packageName": "@omgjs/labkit-webapp-graphql-relay" +} diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index fe2dcaf..acf840c 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -302,6 +302,9 @@ importers: '@types/node': specifier: ^25.6.0 version: 25.6.0 + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 '@types/react-relay': specifier: ^18.2.1 version: 18.2.1 @@ -387,6 +390,70 @@ importers: specifier: ^10.3.0 version: 10.3.0 + ../../tools/webapp-graphql-relay-lifecycle-fixture: + dependencies: + '@omgjs/labkit-webapp-graphql-relay': + specifier: workspace:* + version: link:../../packages/webapp-graphql-relay + '@tanstack/react-router': + specifier: 1.170.25 + version: 1.170.25(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + react: + specifier: 19.2.6 + version: 19.2.6 + react-dom: + specifier: 19.2.6 + version: 19.2.6(react@19.2.6) + react-relay: + specifier: 20.1.1 + version: 20.1.1(react@19.2.6) + relay-runtime: + specifier: 20.1.1 + version: 20.1.1 + devDependencies: + '@omgjs/labkit-eslint-config': + specifier: workspace:* + version: link:../eslint-config + '@playwright/test': + specifier: 1.62.1 + version: 1.62.1 + '@types/jsdom': + specifier: 30.0.0 + version: 30.0.0 + '@types/node': + specifier: 25.6.0 + version: 25.6.0 + '@types/react': + specifier: 19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: 19.2.4 + version: 19.2.4(@types/react@19.2.14) + '@types/react-relay': + specifier: 18.2.1 + version: 18.2.1 + '@types/relay-runtime': + specifier: 20.1.1 + version: 20.1.1 + eslint: + specifier: 10.3.0 + version: 10.3.0 + jsdom: + specifier: 30.0.1 + version: 30.0.1 + relay-compiler: + specifier: 20.1.1 + version: 20.1.1 + typescript: + specifier: 6.0.3 + version: 6.0.3 + vite: + specifier: 8.2.1 + version: 8.2.1(@types/node@25.6.0) + vitest: + specifier: 4.1.10 + version: 4.1.10(@types/node@25.6.0)(jsdom@30.0.1)(vite@8.2.1(@types/node@25.6.0)) + packages: '@apollo/cache-control-types@1.0.3': @@ -482,6 +549,14 @@ packages: '@apollographql/graphql-playground-html@1.6.29': resolution: {integrity: sha512-xCcXpoz52rI4ksJSdOCxeOCn2DLocxwHf9dVT/Q90Pte1LX+LY+91SFtJF3KXVHH8kEin+g1KKCQPKBjZJfWNA==} + '@asamuzakjp/css-color@6.0.7': + resolution: {integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==} + engines: {node: ^22.13.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@8.3.2': + resolution: {integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==} + engines: {node: ^22.13.0 || >=24.0.0} + '@babel/runtime@7.29.2': resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} @@ -489,6 +564,46 @@ packages: '@borewit/text-codec@0.2.2': resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.1.0': + resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.3.0': + resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.10': + resolution: {integrity: sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.7': + resolution: {integrity: sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -528,6 +643,15 @@ packages: resolution: {integrity: sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@graphql-tools/merge@9.1.9': resolution: {integrity: sha512-iHUWNjRHeQRYdgIMIuChThOwoKzA9vrzYeslgfBo5eUYEyHGZCoDPjAavssoYXLwstYt1dZj2J22jSzc2DrN0Q==} engines: {node: '>=16.0.0'} @@ -575,6 +699,9 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@lukeed/csprng@1.1.0': resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} @@ -692,10 +819,18 @@ packages: engines: {node: ^14.18.0 || >=16.10.0, npm: '>=5.10.0'} hasBin: true + '@oxc-project/types@0.143.0': + resolution: {integrity: sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + engines: {node: '>=20'} + hasBin: true + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -726,9 +861,129 @@ packages: '@protobufjs/utf8@1.1.1': resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} + '@rolldown/binding-android-arm64@1.2.3': + resolution: {integrity: sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.3': + resolution: {integrity: sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.3': + resolution: {integrity: sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.3': + resolution: {integrity: sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.3': + resolution: {integrity: sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.3': + resolution: {integrity: sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.3': + resolution: {integrity: sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.3': + resolution: {integrity: sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.3': + resolution: {integrity: sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.3': + resolution: {integrity: sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.3': + resolution: {integrity: sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.3': + resolution: {integrity: sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.3': + resolution: {integrity: sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.3': + resolution: {integrity: sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@sqltools/formatter@1.2.5': resolution: {integrity: sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tanstack/history@1.162.1': + resolution: {integrity: sha512-DR9t6lfLVdrjgCwpglrR9DR7Ok8/HlXjcOE+goWXF3zyuLUO/ug7vMbSFxTqrQTtbRghJfyhmIZ0S6LhPIy44w==} + engines: {node: '>=20.19'} + + '@tanstack/react-router@1.170.25': + resolution: {integrity: sha512-XiWYvkLAGhcZHhV2xUvicpF/VfTVSnewP6CqviDONAjmD50tBob5x/93u2W8QZAc/JgkPd+jijCmu6t4NCzmew==} + engines: {node: '>=20.19'} + peerDependencies: + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + + '@tanstack/react-store@0.9.3': + resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/router-core@1.171.21': + resolution: {integrity: sha512-t6xUHBO94nQDrPHPh6ag5UuKHWIkVjq9s63q2ctbxgzq3vtSoGKmAIdLD5o+NU6JUS1ppXRHgL0YGzEHvH32Ng==} + engines: {node: '>=20.19'} + + '@tanstack/store@0.9.3': + resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} + '@tokenizer/inflate@0.4.1': resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} engines: {node: '>=18'} @@ -736,12 +991,21 @@ packages: '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/esrecurse@4.3.1': resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/jsdom@30.0.0': + resolution: {integrity: sha512-uAHGxujGE0cDaKGdK28zgDotFtNA7MKq5DXl8LrfdxdCI8VHcg15oJz+amHTChPNI5JpgEPQWc2xFdrw3em/nQ==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -751,6 +1015,11 @@ packages: '@types/node@25.6.0': resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} + '@types/react-dom@19.2.4': + resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==} + peerDependencies: + '@types/react': ^19.2.0 + '@types/react-relay@18.2.1': resolution: {integrity: sha512-KgmFapsxAylhxcFfaAv5GZZJhTHnDvV8IDZVsUm5afpJUvgZC1Y68ssfOGsFfiFY/2EhxHM/YPfpdKbfmF3Ecg==} @@ -760,6 +1029,9 @@ packages: '@types/relay-runtime@20.1.1': resolution: {integrity: sha512-loM2iJteknnJcsxrynmBVb7pIDkSkJUuCMnAa/oDFcrvaxkDvq8iy0J2UshMdDy14iJJocDblXeAu7c6Q1+Ucw==} + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@typescript-eslint/eslint-plugin@8.59.2': resolution: {integrity: sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -819,6 +1091,35 @@ packages: resolution: {integrity: sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + '@whatwg-node/promise-helpers@1.3.2': resolution: {integrity: sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==} engines: {node: '>=16.0.0'} @@ -863,6 +1164,10 @@ packages: asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + async-retry@1.3.3: resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} @@ -883,6 +1188,9 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -917,6 +1225,10 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -943,6 +1255,12 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-es@3.1.1: + resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + cors@2.8.6: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} @@ -958,12 +1276,20 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + cssfilter@0.0.10: resolution: {integrity: sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==} csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + dayjs@1.11.20: resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} @@ -976,6 +1302,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + dedent@1.7.2: resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} peerDependencies: @@ -995,6 +1324,10 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} @@ -1019,6 +1352,10 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -1027,6 +1364,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -1086,6 +1426,9 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -1093,6 +1436,10 @@ packages: eventemitter3@3.1.2: resolution: {integrity: sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q==} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1162,6 +1509,16 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -1244,6 +1601,10 @@ packages: resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} engines: {node: '>= 0.4'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -1293,6 +1654,9 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-typed-array@1.1.15: resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} engines: {node: '>= 0.4'} @@ -1300,6 +1664,10 @@ packages: isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + isbot@5.2.1: + resolution: {integrity: sha512-dJ+LpKyClQZ7NG+j3OensC/mAZkGpukE9YUrgPYvAZj2doVL0edfDgywTUh5CXa0o+nW9a1V9e5+CJTX8+SxRw==} + engines: {node: '>=18'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1316,6 +1684,15 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + jsdom@30.0.1: + resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + peerDependencies: + canvas: ^3.2.3 + peerDependenciesMeta: + canvas: + optional: true + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -1332,6 +1709,80 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + load-esm@1.0.3: resolution: {integrity: sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==} engines: {node: '>=13.2.0'} @@ -1371,10 +1822,20 @@ packages: resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==} engines: {node: 20 || >=22} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + media-typer@1.1.0: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} @@ -1410,6 +1871,11 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -1441,6 +1907,10 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -1460,6 +1930,9 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -1479,6 +1952,12 @@ packages: path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@2.3.2: resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} @@ -1487,10 +1966,28 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -1513,6 +2010,11 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + react-dom@19.2.6: + resolution: {integrity: sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==} + peerDependencies: + react: ^19.2.6 + react-relay@20.1.1: resolution: {integrity: sha512-pwl7wHHXCOx32dOg4TVNkhVojgGVvOBMo9pcMGeM1BIFYvmwGauKTtBB+qr5buXHGooCL8TXjMVg+ZLizIsbeQ==} peerDependencies: @@ -1529,6 +2031,10 @@ packages: reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + relay-compiler@20.1.1: + resolution: {integrity: sha512-J/FFFLS/3vnbDkyQMw8l3Ev7dNHXMgC1RAs0fITz4Q63TFPOw142knKxY1Mm5ZZBABkAs9g2JghXaqM+phwTxw==} + hasBin: true + relay-runtime@20.1.1: resolution: {integrity: sha512-N98ZkkyuIHdXmHaPuljihM1QbFYXATF0gxA0CESFphszsQzXs9A/zZloVfzdOI/xg3B3CfM/5nozvIoeTDIcfw==} @@ -1536,6 +2042,10 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + retry@0.13.1: resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} engines: {node: '>= 4'} @@ -1544,6 +2054,11 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rolldown@1.2.3: + resolution: {integrity: sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} @@ -1556,11 +2071,28 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + semver@7.7.4: resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} engines: {node: '>=10'} hasBin: true + seroval-plugins@1.6.2: + resolution: {integrity: sha512-TfxuUjlbBESzUOWdTkTKqvSmav0ABym+itetDXLK6mDz8SmrpdI30aF8RTXE8Bvq+tH/1yIDkvy3W0lfQb1ipQ==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.6.2: + resolution: {integrity: sha512-mPT+SD2TrlB6wvte1KkYOYUkubaTbd6pZ/6Kk3C9nxzrHmCZyhxOO7XGAeL7f+yLKZglzGtM9odUVvg/EhO+vQ==} + engines: {node: '>=10'} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -1600,18 +2132,31 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + sql-highlight@6.1.0: resolution: {integrity: sha512-ed7OK4e9ywpE7pgRMkMQmZDPKSVdm0oX5IEtZiKnFucSF0zu6c80GZBe38UqHuVhTWJ9xsKgSMjCG2bml86KvA==} engines: {node: '>=14'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -1642,10 +2187,35 @@ packages: resolution: {integrity: sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==} engines: {node: '>=0.10.0'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + tinyglobby@0.2.16: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + + tldts-core@7.4.10: + resolution: {integrity: sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==} + + tldts@7.4.10: + resolution: {integrity: sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==} + hasBin: true + to-buffer@1.2.2: resolution: {integrity: sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==} engines: {node: '>= 0.4'} @@ -1662,9 +2232,17 @@ packages: resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} engines: {node: '>=14.16'} + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -1768,6 +2346,13 @@ packages: undici-types@7.19.2: resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + undici-types@8.10.0: + resolution: {integrity: sha512-ibvdovq3nCFs8Msrd95BW+zUOq+aOVbT+wpHUoPWhztbHEoPc6oof51iFDB6Es8lTKvNvVW9jNSAB8dwrKTMGg==} + + undici@8.10.0: + resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==} + engines: {node: '>=22.19.0'} + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -1775,6 +2360,11 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + uuid@11.1.1: resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} hasBin: true @@ -1783,13 +2373,117 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vite@8.2.1: + resolution: {integrity: sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + whatwg-url@17.1.0: + resolution: {integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==} + engines: {node: ^22.14.0 || >=24.0.0} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -1802,6 +2496,11 @@ packages: engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -1838,6 +2537,13 @@ packages: utf-8-validate: optional: true + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xss@1.0.15: resolution: {integrity: sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==} engines: {node: '>= 0.10.0'} @@ -1976,10 +2682,53 @@ snapshots: dependencies: xss: 1.0.15 + '@asamuzakjp/css-color@6.0.7': + dependencies: + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.5.2 + + '@asamuzakjp/dom-selector@8.3.2': + dependencies: + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + '@babel/runtime@7.29.2': {} '@borewit/text-codec@0.2.2': {} + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.1.0': {} + + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.0 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.7(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@eslint-community/eslint-utils@4.9.1(eslint@10.3.0)': dependencies: eslint: 10.3.0 @@ -2014,6 +2763,8 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 + '@exodus/bytes@1.15.1': {} + '@graphql-tools/merge@9.1.9(graphql@16.14.0)': dependencies: '@graphql-tools/utils': 11.1.0(graphql@16.14.0) @@ -2064,6 +2815,8 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 + '@jridgewell/sourcemap-codec@1.5.5': {} + '@lukeed/csprng@1.1.0': {} '@nestjs/apollo@13.4.0(@apollo/server@5.5.1(graphql@16.14.0))(@nestjs/common@11.1.19(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19(@nestjs/common@11.1.19(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/graphql@13.4.0(@nestjs/common@11.1.19(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19(@nestjs/common@11.1.19(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2))(graphql@16.14.0)(reflect-metadata@0.2.2))(graphql@16.14.0)': @@ -2156,9 +2909,15 @@ snapshots: dependencies: consola: 3.4.2 + '@oxc-project/types@0.143.0': {} + '@pkgjs/parseargs@0.11.0': optional: true + '@playwright/test@1.62.1': + dependencies: + playwright: 1.62.1 + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -2182,8 +2941,81 @@ snapshots: '@protobufjs/utf8@1.1.1': {} + '@rolldown/binding-android-arm64@1.2.3': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.3': + optional: true + + '@rolldown/binding-darwin-x64@1.2.3': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.3': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.3': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.3': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.3': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.3': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.3': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.3': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.3': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.3': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.3': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.3': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + '@sqltools/formatter@1.2.5': {} + '@standard-schema/spec@1.1.0': {} + + '@tanstack/history@1.162.1': {} + + '@tanstack/react-router@1.170.25(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@tanstack/history': 1.162.1 + '@tanstack/react-store': 0.9.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@tanstack/router-core': 1.171.21 + isbot: 5.2.1 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + '@tanstack/react-store@0.9.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@tanstack/store': 0.9.3 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + use-sync-external-store: 1.6.0(react@19.2.6) + + '@tanstack/router-core@1.171.21': + dependencies: + '@tanstack/history': 1.162.1 + cookie-es: 3.1.1 + seroval: 1.6.2 + seroval-plugins: 1.6.2(seroval@1.6.2) + + '@tanstack/store@0.9.3': {} + '@tokenizer/inflate@0.4.1': dependencies: debug: 4.4.3 @@ -2193,10 +3025,24 @@ snapshots: '@tokenizer/token@0.3.0': {} + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + '@types/esrecurse@4.3.1': {} '@types/estree@1.0.9': {} + '@types/jsdom@30.0.0': + dependencies: + '@types/node': 25.6.0 + '@types/tough-cookie': 4.0.5 + parse5: 8.0.1 + undici-types: 8.10.0 + '@types/json-schema@7.0.15': {} '@types/long@4.0.2': {} @@ -2205,6 +3051,10 @@ snapshots: dependencies: undici-types: 7.19.2 + '@types/react-dom@19.2.4(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + '@types/react-relay@18.2.1': dependencies: '@types/react': 19.2.14 @@ -2216,6 +3066,8 @@ snapshots: '@types/relay-runtime@20.1.1': {} + '@types/tough-cookie@4.0.5': {} + '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@10.3.0)(typescript@6.0.3))(eslint@10.3.0)(typescript@6.0.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -2307,6 +3159,47 @@ snapshots: '@typescript-eslint/types': 8.59.2 eslint-visitor-keys: 5.0.1 + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.10(vite@8.2.1(@types/node@25.6.0))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.2.1(@types/node@25.6.0) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + '@whatwg-node/promise-helpers@1.3.2': dependencies: tslib: 2.8.1 @@ -2340,6 +3233,8 @@ snapshots: asap@2.0.6: {} + assertion-error@2.0.1: {} + async-retry@1.3.3: dependencies: retry: 0.13.1 @@ -2356,6 +3251,10 @@ snapshots: base64-js@1.5.1: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + body-parser@2.2.2: dependencies: bytes: 3.1.2 @@ -2406,6 +3305,8 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 + chai@6.2.2: {} + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -2428,6 +3329,10 @@ snapshots: content-type@1.0.5: {} + convert-source-map@2.0.0: {} + + cookie-es@3.1.1: {} + cors@2.8.6: dependencies: object-assign: 4.1.1 @@ -2449,16 +3354,30 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + cssfilter@0.0.10: {} csstype@3.2.3: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + dayjs@1.11.20: {} debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + dedent@1.7.2: {} deep-is@0.1.4: {} @@ -2471,6 +3390,8 @@ snapshots: depd@2.0.0: {} + detect-libc@2.1.2: {} + dotenv@16.6.1: {} dunder-proto@1.0.1: @@ -2489,10 +3410,14 @@ snapshots: encodeurl@2.0.0: {} + entities@8.0.0: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} + es-module-lexer@2.3.1: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -2569,10 +3494,16 @@ snapshots: estraverse@5.3.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + esutils@2.0.3: {} eventemitter3@3.1.2: {} + expect-type@1.4.0: {} + fast-deep-equal@3.1.3: {} fast-glob@3.3.3: @@ -2611,6 +3542,10 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -2660,6 +3595,12 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + function-bind@1.1.2: {} get-caller-file@2.0.5: {} @@ -2735,6 +3676,12 @@ snapshots: dependencies: function-bind: 1.1.2 + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -2773,12 +3720,16 @@ snapshots: is-number@7.0.0: {} + is-potential-custom-element-name@1.0.1: {} + is-typed-array@1.1.15: dependencies: which-typed-array: 1.1.20 isarray@2.0.5: {} + isbot@5.2.1: {} + isexe@2.0.0: {} iterall@1.3.0: {} @@ -2793,6 +3744,32 @@ snapshots: js-tokens@4.0.0: {} + jsdom@30.0.1: + dependencies: + '@asamuzakjp/css-color': 6.0.7 + '@asamuzakjp/dom-selector': 8.3.2 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.7(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 8.10.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 17.1.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + json-buffer@3.0.1: {} json-schema-traverse@0.4.1: {} @@ -2808,6 +3785,55 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + load-esm@1.0.3: {} locate-path@6.0.0: @@ -2834,8 +3860,16 @@ snapshots: lru-cache@11.3.6: {} + lru-cache@11.5.2: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + math-intrinsics@1.1.0: {} + mdn-data@2.27.1: {} + media-typer@1.1.0: {} merge2@1.4.1: {} @@ -2863,6 +3897,8 @@ snapshots: ms@2.1.3: {} + nanoid@3.3.18: {} + natural-compare@1.4.0: {} negotiator@1.0.0: {} @@ -2879,6 +3915,8 @@ snapshots: object-inspect@1.13.4: {} + obug@2.1.4: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -2902,6 +3940,10 @@ snapshots: package-json-from-dist@1.0.1: {} + parse5@8.0.1: + dependencies: + entities: 8.0.0 + parseurl@1.3.3: {} path-exists@4.0.0: {} @@ -2915,12 +3957,32 @@ snapshots: path-to-regexp@8.4.2: {} + pathe@2.0.3: {} + + picocolors@1.1.1: {} + picomatch@2.3.2: {} picomatch@4.0.4: {} + picomatch@4.0.5: {} + + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + possible-typed-array-names@1.1.0: {} + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + prelude-ls@1.2.1: {} promise@7.3.1: @@ -2942,6 +4004,11 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 + react-dom@19.2.6(react@19.2.6): + dependencies: + react: 19.2.6 + scheduler: 0.27.0 + react-relay@20.1.1(react@19.2.6): dependencies: '@babel/runtime': 7.29.2 @@ -2959,6 +4026,8 @@ snapshots: reflect-metadata@0.2.2: {} + relay-compiler@20.1.1: {} + relay-runtime@20.1.1: dependencies: '@babel/runtime': 7.29.2 @@ -2969,10 +4038,32 @@ snapshots: require-directory@2.1.1: {} + require-from-string@2.0.2: {} + retry@0.13.1: {} reusify@1.1.0: {} + rolldown@1.2.3: + dependencies: + '@oxc-project/types': 0.143.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.2.3 + '@rolldown/binding-darwin-arm64': 1.2.3 + '@rolldown/binding-darwin-x64': 1.2.3 + '@rolldown/binding-freebsd-x64': 1.2.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.3 + '@rolldown/binding-linux-arm64-gnu': 1.2.3 + '@rolldown/binding-linux-arm64-musl': 1.2.3 + '@rolldown/binding-linux-ppc64-gnu': 1.2.3 + '@rolldown/binding-linux-s390x-gnu': 1.2.3 + '@rolldown/binding-linux-x64-gnu': 1.2.3 + '@rolldown/binding-linux-x64-musl': 1.2.3 + '@rolldown/binding-openharmony-arm64': 1.2.3 + '@rolldown/binding-win32-arm64-msvc': 1.2.3 + '@rolldown/binding-win32-x64-msvc': 1.2.3 + run-parallel@1.2.0: dependencies: queue-microtask: 1.2.3 @@ -2985,8 +4076,20 @@ snapshots: safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.27.0: {} + semver@7.7.4: {} + seroval-plugins@1.6.2(seroval@1.6.2): + dependencies: + seroval: 1.6.2 + + seroval@1.6.2: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -3040,12 +4143,20 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@4.1.0: {} + source-map-js@1.2.1: {} + sql-highlight@6.1.0: {} + stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@4.2.0: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -3084,11 +4195,30 @@ snapshots: symbol-observable@1.2.0: {} + symbol-tree@3.2.4: {} + + tinybench@2.9.0: {} + + tinyexec@1.3.0: {} + tinyglobby@0.2.16: dependencies: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.1: {} + + tldts-core@7.4.10: {} + + tldts@7.4.10: + dependencies: + tldts-core: 7.4.10 + to-buffer@1.2.2: dependencies: isarray: 2.0.5 @@ -3107,8 +4237,16 @@ snapshots: '@tokenizer/token': 0.3.0 ieee754: 1.2.1 + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.10 + tr46@0.0.3: {} + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + ts-api-utils@2.5.0(typescript@6.0.3): dependencies: typescript: 6.0.3 @@ -3175,20 +4313,91 @@ snapshots: undici-types@7.19.2: {} + undici-types@8.10.0: {} + + undici@8.10.0: {} + unpipe@1.0.0: {} uri-js@4.4.1: dependencies: punycode: 2.3.1 + use-sync-external-store@1.6.0(react@19.2.6): + dependencies: + react: 19.2.6 + uuid@11.1.1: {} vary@1.1.2: {} + vite@8.2.1(@types/node@25.6.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.26 + rolldown: 1.2.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 25.6.0 + fsevents: 2.3.3 + + vitest@4.1.10(@types/node@25.6.0)(jsdom@30.0.1)(vite@8.2.1(@types/node@25.6.0)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@25.6.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.2.1(@types/node@25.6.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.6.0 + jsdom: 30.0.1 + transitivePeerDependencies: + - msw + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + webidl-conversions@3.0.1: {} + webidl-conversions@8.0.1: {} + whatwg-mimetype@4.0.0: {} + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + whatwg-url@17.1.0: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -3208,6 +4417,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} wrap-ansi@7.0.0: @@ -3226,6 +4440,10 @@ snapshots: ws@8.20.0: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + xss@1.0.15: dependencies: commander: 2.20.3 diff --git a/docs/architecture.md b/docs/architecture.md index 466db62..f30a0e0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -87,7 +87,8 @@ user-facing behavior. Labkit helps the browser wire the runtime: stale refresh token fails server-side. - `@omgjs/labkit-webapp-graphql-relay` creates an auth-aware Relay environment, retries one non-auth GraphQL operation after refresh, wires GraphQL - subscriptions, and disposes route preloads when navigation aborts. + subscriptions, and coordinates route-query ownership across loader abort and + mounted React consumers before disposal. - `@omgjs/labkit-webapp-realtime` provides a self-healing websocket runtime with a stable GraphQL WS client facade, connection-state monitoring, heartbeat/watchdog policy, browser online/resume recovery, fatal close-code @@ -97,8 +98,11 @@ user-facing behavior. Labkit helps the browser wire the runtime: class application, while concrete components remain app-owned. The app still owns routes, generated operation files, endpoint resolution, -React hooks, form components, token storage policy selection, CSS classes, -theme values, and product UI. +product React hooks, form components, token storage policy selection, CSS +classes, theme values, and product UI. Browser packages may expose focused +framework hooks when a reusable runtime resource needs framework lifecycle +integration; those hooks must remain independent of app route definitions and +product behavior. ## Shared Boundary diff --git a/docs/graphql-contract.md b/docs/graphql-contract.md index c176cda..dc4a8a9 100644 --- a/docs/graphql-contract.md +++ b/docs/graphql-contract.md @@ -39,12 +39,40 @@ The browser should: - generate Relay artifacts from the server schema; - create one `DefaultWebappRelayRuntime` with auth-aware HTTP fetch, auth-aware websocket reconnects, and websocket subscription support; -- preload route queries through route loaders and dispose aborted preloads; +- preload route queries through route loaders, give mounted query references an + explicit route-query lifetime, and dispose them after their final owner; - update Relay store records explicitly for mutations/subscriptions when needed. `@omgjs/labkit-webapp-graphql-relay` owns the reusable network behavior, but the app owns operation files and route loaders. +## Route Query Ownership + +Network completion is not a release event for a Relay preloaded query +reference. A router loader/preload and its mounted React consumer are separate +owners: replacement navigation can retire the loader invocation while React +still presents the previous route. + +For mounted route queries, the loader creates one +`createRouteQueryLifetime(...)`, passes that lifetime to every +`loadRouteQuery(...)` call in that loader, and returns the lifetime with the +references. The route component calls `useRouteQueryLifetime(...)` before +consuming those references. A route abort releases loader ownership; the final +mounted release performs disposal. Partial multi-query construction calls +terminal `abort(error)` before rethrowing. + +The `loadRouteQuery` ownership inputs are exclusive. `lifetime` is the safe +path for mounted route queries. The deprecated raw `abortSignal` input remains +only for abort-scoped work guaranteed never to become a mounted React +resource. The lifetime's child signal remains available when the application +must compose other abort-aware resources into the same ownership domain. + +The app continues to own route structure, generated operations, cache and +freshness settings, pending/error UI, history, and retry. Labkit owns the +router-independent lifetime accounting and idempotent terminal abort. See the +[3.1 upgrade guide](upgrades/webapp-graphql-relay-3.1.md) for the validated +TanStack policy and migration examples. + ## Minimal Flow ```text diff --git a/docs/packages/webapp-graphql-relay.md b/docs/packages/webapp-graphql-relay.md index 0df1f34..780f246 100644 --- a/docs/packages/webapp-graphql-relay.md +++ b/docs/packages/webapp-graphql-relay.md @@ -113,6 +113,9 @@ release. - `createWebappRelayEnvironment`; - `createWebappRelayRealtimeClient`; - `terminateRealtimeClientOnAuthTokenChange`; +- `createRouteQueryLifetime` and `useRouteQueryLifetime`; +- `LoadRouteQueryLifetimeOptions` for mounted route-query wrappers; +- deprecated `LoadRouteQueryOptions` for abort-only compatibility wrappers; - `loadRouteQuery`; - `appendRootFieldRecordIfMissing`. @@ -120,8 +123,8 @@ release. This package owns the Relay network mechanics: bearer auth headers, one refresh retry for non-auth operations, auth-aware websocket connection params, -websocket subscription integration, auth-token change socket termination, route -preload disposal, and a small store updater. +websocket subscription integration, auth-token change socket termination, +route-query lifetime ownership, and a small store updater. The recommended default is `DefaultWebappRelayRuntime`, which composes the Relay environment and Labkit realtime runtime so websocket recovery can refresh auth @@ -182,6 +185,72 @@ export function createRelayEnvironment() { } ``` +## Route Query Lifetimes + +A router loader signal represents loader/preload ownership, but mounted React +UI can outlive that signal while replacement work is pending. For query +references consumed by a route component, create a router-independent lifetime +and mount its hook: + +```tsx +import { + createRouteQueryLifetime, + loadRouteQuery, + useRouteQueryLifetime, +} from "@omgjs/labkit-webapp-graphql-relay"; +import { usePreloadedQuery } from "react-relay"; + +const itemRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "items/$itemId", + pendingComponent: ItemPending, + loader: ({ abortController, context, params }) => { + const queryLifetime = createRouteQueryLifetime({ + routeAbortSignal: abortController.signal, + }); + + try { + return { + queryLifetime, + queryRef: loadRouteQuery({ + environment: context.relayEnvironment, + lifetime: queryLifetime, + query: ItemRouteQuery, + variables: { id: params.itemId }, + }), + }; + } catch (error) { + queryLifetime.abort(error); + throw error; + } + }, + component: ItemRoute, +}); + +function ItemRoute() { + const { queryLifetime, queryRef } = itemRoute.useLoaderData(); + useRouteQueryLifetime(queryLifetime); + const data = usePreloadedQuery(ItemRouteQuery, queryRef); + + return ; +} +``` + +Use one lifetime for all query references constructed by one loader, and call +terminal `abort(error)` before rethrowing a partial-construction error. The +`lifetime` and `abortSignal` options are mutually exclusive. The raw signal +option is deprecated and is retained only for abort-scoped work that can never +become a mounted React resource. The application still owns route definitions, +pending/error UI, freshness/history policy, and retry. The tested TanStack +configuration uses an explicit pending component, `defaultGcTime: 0`, +`defaultStaleTime: 0`, and +`defaultStaleReloadMode: "blocking"` so retired query-reference loader data is +replaced before render. + +See [Upgrade Webapp GraphQL Relay To +3.1](../upgrades/webapp-graphql-relay-3.1.md) for complete migration, +multi-query, preload, history, Strict Mode, retry, and teardown guidance. + ## Runtime Notes The default auth operation names are `LoginMutation`, `RegisterMutation`, diff --git a/docs/upgrades/webapp-graphql-relay-3.1.md b/docs/upgrades/webapp-graphql-relay-3.1.md new file mode 100644 index 0000000..fc70781 --- /dev/null +++ b/docs/upgrades/webapp-graphql-relay-3.1.md @@ -0,0 +1,240 @@ +# Upgrade Webapp GraphQL Relay To 3.1 + +`@omgjs/labkit-webapp-graphql-relay` 3.1 adds explicit route-query lifetime +ownership for Relay query references created by a router loader. Applications +that pass a TanStack Router loader signal directly to `loadRouteQuery` should +migrate mounted route queries to the lifetime API. + +The change is additive. Existing raw-`abortSignal` calls still compile and keep +their abort-scoped semantics, but that input is now deprecated. A loader signal +alone is not sufficient when React can keep the previous route mounted while +replacement data is pending. + +## Upgrade The Package + +Keep the application-owned Relay pair exact while upgrading Labkit: + +```bash +npm install @omgjs/labkit-webapp-graphql-relay@^3.1.0 \ + react-relay@20.1.1 relay-runtime@20.1.1 +``` + +With pnpm: + +```bash +pnpm add @omgjs/labkit-webapp-graphql-relay@^3.1.0 \ + react-relay@20.1.1 relay-runtime@20.1.1 +``` + +Run the Relay compiler and TypeScript check after the code migration below. +The 3.1 package keeps the 3.0 Relay peer contract and adds no TanStack runtime +dependency. + +## Who Needs To Migrate + +Migrate when all of these are true: + +- a route loader creates one or more Relay preloaded query references; +- the route component consumes them with `usePreloadedQuery`; +- the loader passes its router abort signal directly to `loadRouteQuery`. + +Code that loads work which is guaranteed never to become a mounted React +resource can temporarily keep the deprecated raw `abortSignal` path. Do not +use that path for a preload that may later be promoted into a mounted route. + +The required Relay peer pair remains `react-relay@20.1.1` and +`relay-runtime@20.1.1`. + +## Before + +This pattern can dispose the previous route's query reference while React is +still presenting that route during a pending navigation: + +```ts +loader: ({ abortController, context, params }) => ({ + queryRef: loadRouteQuery({ + abortSignal: abortController.signal, + environment: context.relayEnvironment, + query: UserRouteQuery, + variables: { id: params.userId }, + }), +}); +``` + +## After: One Query + +Create one lifetime for the loader invocation. Pass it to `loadRouteQuery`, +return the lifetime with the query reference, and mount the lifetime hook in +the route component before consuming the query reference. + +```tsx +import { + createRouteQueryLifetime, + loadRouteQuery, + useRouteQueryLifetime, +} from "@omgjs/labkit-webapp-graphql-relay"; +import { usePreloadedQuery } from "react-relay"; + +export const userRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "users/$userId", + pendingComponent: UserRoutePending, + loader: ({ abortController, context, params }) => { + const queryLifetime = createRouteQueryLifetime({ + routeAbortSignal: abortController.signal, + }); + + try { + return { + queryLifetime, + queryRef: loadRouteQuery({ + environment: context.relayEnvironment, + fetchPolicy: "store-and-network", + lifetime: queryLifetime, + query: UserRouteQuery, + variables: { id: params.userId }, + }), + }; + } catch (error) { + queryLifetime.abort(error); + throw error; + } + }, + component: UserRoute, +}); + +function UserRoute() { + const { queryLifetime, queryRef } = userRoute.useLoaderData(); + useRouteQueryLifetime(queryLifetime); + const data = usePreloadedQuery(UserRouteQuery, queryRef); + + return
{data.user.name}
; +} +``` + +The route signal owns the lifetime while loader work is pending or cached. The +hook adds mounted ownership. A router abort releases the route owner, but Relay +work is disposed only after the final mounted owner also releases it. Network +completion does not release ownership. + +## Multiple Queries And Partial Construction + +Use one lifetime for every query reference created by the same loader. If +construction fails after any reference was created, call `abort(error)` before +rethrowing so all already-created work is released. + +```ts +loader: ({ abortController, context, params }) => { + const queryLifetime = createRouteQueryLifetime({ + routeAbortSignal: abortController.signal, + }); + + try { + const profileQueryRef = loadRouteQuery({ + environment: context.relayEnvironment, + lifetime: queryLifetime, + query: ProfileRouteQuery, + variables: { id: params.userId }, + }); + const permissionsQueryRef = loadRouteQuery({ + environment: context.relayEnvironment, + lifetime: queryLifetime, + query: PermissionsRouteQuery, + variables: { id: params.userId }, + }); + + return { queryLifetime, profileQueryRef, permissionsQueryRef }; + } catch (error) { + queryLifetime.abort(error); + throw error; + } +}; +``` + +The component calls `useRouteQueryLifetime(queryLifetime)` once, then consumes +both references. The references may resolve, fail, or retry independently; the +lifetime releases the group only when its final owner is gone. + +The `lifetime` and `abortSignal` inputs are mutually exclusive. The lifetime's +child `abortSignal` is still available for composing other abort-aware work +into the same ownership domain, but it should not be passed back to +`loadRouteQuery`. + +If application code wraps `loadRouteQuery`, change mounted-route wrapper +parameters to `LoadRouteQueryLifetimeOptions`. The existing +`LoadRouteQueryOptions` name remains source-compatible for abort-only +wrappers, but it is deprecated. Keeping separate public types avoids forcing a +3.1 compile break on existing wrappers while making the lifetime-first overload +the primary API. + +## Router Policy + +The validated TanStack Router policy reloads before rendering a query reference +from a retired loader invocation: + +```ts +const router = createRouter({ + routeTree, + context, + defaultGcTime: 0, + defaultStaleTime: 0, + defaultPreloadStaleTime: 30_000, + defaultStaleReloadMode: "blocking", +}); +``` + +Also provide an explicit route `pendingComponent`. Keep these ownership rules: + +- do not reuse loader data containing a released query reference; +- do not use a timeout or grace period to delay disposal; +- let the router signal release loader/preload ownership; +- let `useRouteQueryLifetime` release mounted ownership; +- call terminal `abort(error)` on partial construction failure; +- create a fresh lifetime and fresh references for retry or replacement; +- keep product history, freshness, retry UI, and route structure app-owned. + +Preload promotion uses the same loader lifetime. An unused preload is released +when the router retires or clears it. A promoted preload gains mounted ownership +when its component commits. + +## Strict Mode And Suspense + +No application-specific Strict Mode workaround is required. The hook holds +complementary commit-time and passive mounted leases. This covers the interval +before passive effects run and prevents Suspense's temporary layout-effect +disconnection from releasing a query reference whose previous UI is still +visible. + +Do not call `createRouteQueryLifetime` or `loadRouteQuery` during React render. +Create them in the loader and call the hook unconditionally in the route +component. + +## Retry, Errors, And Teardown + +On retry or invalidation, create a new loader lifetime. The old lifetime stays +valid while its mounted UI remains visible and releases after replacement +commits. Late responses from superseded work cannot re-own a terminal lifetime. + +For application shutdown or a recreated router/runtime: + +1. navigate to a route that owns no Relay query references when practical; +2. clear retired router cache entries; +3. unmount the React root; +4. dispose the application Relay/realtime runtime. + +Every release callback and terminal `abort()` call is idempotent. Do not retain +a lifetime or query reference across Relay environment or router recreation. + +## Verification Checklist + +After migrating: + +1. run Relay compilation, TypeScript checking, and the production Vite build; +2. verify initial entry, pending replacement, rapid navigation, back/forward, + preload promotion/cancel, retry, and final teardown; +3. repeat pending replacement and multi-query paths under React Strict Mode; +4. confirm the supported single Relay runtime graph described in the + [package reference](../packages/webapp-graphql-relay.md#required-relay-peers). + +See [Webapp Composition](../webapp-composition.md) for the application boundary +and the [GraphQL Contract](../graphql-contract.md) for query ownership rules. diff --git a/docs/webapp-composition.md b/docs/webapp-composition.md index 3962a5c..2a1b81a 100644 --- a/docs/webapp-composition.md +++ b/docs/webapp-composition.md @@ -92,5 +92,21 @@ export function AppProviders() { } ``` -Labkit owns the mechanics. The app owns routes, generated operations, hooks, -UI, and visual design. +Labkit owns reusable runtime and resource-lifetime mechanics. The app owns +routes, generated operations, product hooks, UI, and visual design. + +## Route-Owned Relay Queries + +When a TanStack loader creates a Relay query reference that a route component +mounts, use `createRouteQueryLifetime` to bridge loader ownership and mounted +React ownership. Return the lifetime with the references and call +`useRouteQueryLifetime` in the route component. Use one lifetime for all +references created by one loader and call terminal `abort(error)` if +multi-query construction fails partway. + +The application still chooses pending/error components, freshness and cache +settings, history behavior, retry UI, and generated operations. The validated +policy replaces retired loader data before render with `defaultGcTime: 0`, +`defaultStaleTime: 0`, and blocking stale reloads. See the +[3.1 Relay upgrade guide](upgrades/webapp-graphql-relay-3.1.md) for complete +loader/component examples and teardown rules. diff --git a/packages/webapp-graphql-relay/README.md b/packages/webapp-graphql-relay/README.md index 0b08865..fb78f28 100644 --- a/packages/webapp-graphql-relay/README.md +++ b/packages/webapp-graphql-relay/README.md @@ -72,7 +72,8 @@ for the complete migration checklist. - Default realtime runtime creation when only websocket options are provided. - Realtime client termination after auth-token changes. - Realtime connection-state access for UI. -- Route query preload with abort disposal. +- Route-query lifetime ownership across loader abort, mounted React consumers, + Suspense replacement, and final disposal. - Root-field store updater helper. - Unauthorized GraphQL response helper. @@ -119,21 +120,69 @@ Advanced applications can still provide their own realtime adapter to `createAuthAwareRelayGraphqlWsConnectionParams` directly when replacing only one runtime policy. -For TanStack Router loaders: - -```ts -import { loadRouteQuery } from "@omgjs/labkit-webapp-graphql-relay"; - -loader: ({ abortController, context }) => ({ - queryRef: loadRouteQuery({ - abortSignal: abortController.signal, - environment: context.relayEnvironment, - query: ChatPageQuery, - variables: {}, - }), +For TanStack Router loaders whose query reference is consumed by a mounted +route, create one lifetime per loader invocation and mount that lifetime in the +route component: + +```tsx +import { + createRouteQueryLifetime, + loadRouteQuery, + useRouteQueryLifetime, +} from "@omgjs/labkit-webapp-graphql-relay"; +import { usePreloadedQuery } from "react-relay"; + +const chatRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "chat", + pendingComponent: ChatPending, + loader: ({ abortController, context }) => { + const queryLifetime = createRouteQueryLifetime({ + routeAbortSignal: abortController.signal, + }); + + try { + return { + queryLifetime, + queryRef: loadRouteQuery({ + environment: context.relayEnvironment, + lifetime: queryLifetime, + query: ChatPageQuery, + variables: {}, + }), + }; + } catch (error) { + queryLifetime.abort(error); + throw error; + } + }, + component: ChatRoute, }); + +function ChatRoute() { + const { queryLifetime, queryRef } = chatRoute.useLoaderData(); + useRouteQueryLifetime(queryLifetime); + const data = usePreloadedQuery(ChatPageQuery, queryRef); + + return ; +} ``` +Pass the same lifetime to every query reference created by the same loader. A +router abort releases loader ownership; mounted ownership keeps the reference +usable until replacement commits and the route unmounts. The raw +`abortSignal` option remains as a deprecated compatibility path only for work +that can never become a mounted React resource; do not supply it together with +`lifetime`. Call `queryLifetime.abort(error)` when multi-query construction +fails partway. + +The validated TanStack policy uses an explicit pending component, +`defaultGcTime: 0`, `defaultStaleTime: 0`, and blocking stale reloads so retired +loader data is replaced before render. See the +[3.1 upgrade guide](../../docs/upgrades/webapp-graphql-relay-3.1.md) for +single-query and multi-query migration, preload/history/retry behavior, Strict +Mode, and teardown. + Endpoint resolution intentionally remains app-owned until another browser app needs the same Vite URL policy. diff --git a/packages/webapp-graphql-relay/consumer-test/src/app.tsx b/packages/webapp-graphql-relay/consumer-test/src/app.tsx index 9b2bb90..eac3254 100644 --- a/packages/webapp-graphql-relay/consumer-test/src/app.tsx +++ b/packages/webapp-graphql-relay/consumer-test/src/app.tsx @@ -1,7 +1,10 @@ import { + createRouteQueryLifetime, createWebappRelayEnvironment, loadRouteQuery, + useRouteQueryLifetime, type GraphqlRelayAuthAdapter, + type RouteQueryLifetime, } from "@omgjs/labkit-webapp-graphql-relay"; import { Suspense, type ReactElement } from "react"; import { @@ -32,10 +35,13 @@ const wsClient = { } as unknown as Client; function FixtureView({ + queryLifetime, queryReference, }: { + queryLifetime: RouteQueryLifetime; queryReference: PreloadedQuery; }): ReactElement { + useRouteQueryLifetime(queryLifetime); const data = usePreloadedQuery(fixtureQuery, queryReference); return {data.viewer.name}; } @@ -65,6 +71,9 @@ export function createFixtureTree(): { }, }); const abortController = new AbortController(); + const queryLifetime = createRouteQueryLifetime({ + routeAbortSignal: abortController.signal, + }); const operation = createOperationDescriptor(fixtureQuery, {}); const initialSnapshot = environment.lookup(operation.fragment); let readinessSubscription: Disposable | undefined; @@ -86,9 +95,9 @@ export function createFixtureTree(): { ); }); const queryReference = loadRouteQuery({ - abortSignal: abortController.signal, environment, fetchPolicy: "network-only", + lifetime: queryLifetime, query: fixtureQuery, variables: {}, }); @@ -96,13 +105,16 @@ export function createFixtureTree(): { return { dispose: () => { readinessSubscription?.dispose(); - queryReference.dispose(); + queryLifetime.abort(); }, ready, tree: ( Loading fixture}> - + ), diff --git a/packages/webapp-graphql-relay/consumer-test/src/cjs-smoke.ts b/packages/webapp-graphql-relay/consumer-test/src/cjs-smoke.ts index 721def3..7db5c9a 100644 --- a/packages/webapp-graphql-relay/consumer-test/src/cjs-smoke.ts +++ b/packages/webapp-graphql-relay/consumer-test/src/cjs-smoke.ts @@ -29,6 +29,8 @@ const environment = labkit.createWebappRelayEnvironment({ }); assert.equal(typeof labkit.loadRouteQuery, "function"); +assert.equal(typeof labkit.createRouteQueryLifetime, "function"); +assert.equal(typeof labkit.useRouteQueryLifetime, "function"); assert.ok(environment instanceof relayRuntime.Environment); process.stdout.write( "CommonJS entrypoint uses the application Relay runtime.\n", diff --git a/packages/webapp-graphql-relay/consumer-test/src/esm-smoke.ts b/packages/webapp-graphql-relay/consumer-test/src/esm-smoke.ts index 04a47ed..f5c5ce3 100644 --- a/packages/webapp-graphql-relay/consumer-test/src/esm-smoke.ts +++ b/packages/webapp-graphql-relay/consumer-test/src/esm-smoke.ts @@ -1,7 +1,9 @@ import assert from "node:assert/strict"; import { + createRouteQueryLifetime, createWebappRelayEnvironment, loadRouteQuery, + useRouteQueryLifetime, } from "@omgjs/labkit-webapp-graphql-relay"; import type { Client } from "graphql-ws"; import { Environment } from "relay-runtime"; @@ -32,5 +34,7 @@ const environment = createWebappRelayEnvironment({ }); assert.equal(typeof loadRouteQuery, "function"); +assert.equal(typeof createRouteQueryLifetime, "function"); +assert.equal(typeof useRouteQueryLifetime, "function"); assert.ok(environment instanceof Environment); process.stdout.write("ESM entrypoint uses the application Relay runtime.\n"); diff --git a/packages/webapp-graphql-relay/consumer-test/src/lifetime-type-contract.ts b/packages/webapp-graphql-relay/consumer-test/src/lifetime-type-contract.ts new file mode 100644 index 0000000..34e57dc --- /dev/null +++ b/packages/webapp-graphql-relay/consumer-test/src/lifetime-type-contract.ts @@ -0,0 +1,44 @@ +import type { + LoadRouteQueryLifetimeOptions, + LoadRouteQueryOptions, + RouteAbortSignal, + RouteQueryLifetime, +} from "@omgjs/labkit-webapp-graphql-relay"; +import type { Environment, OperationType } from "relay-runtime"; + +type LifetimeContractQuery = OperationType & { + readonly response: { readonly viewer: { readonly id: string } }; + readonly variables: Record; +}; + +const baseOptions = { + environment: {} as Environment, + query: {} as never, + variables: {}, +}; +const lifetime = {} as RouteQueryLifetime; +const abortSignal = {} as RouteAbortSignal; + +export const lifetimeOptions: LoadRouteQueryLifetimeOptions = + { + ...baseOptions, + lifetime, + }; + +export const legacyAbortOptions: LoadRouteQueryOptions = + { + ...baseOptions, + abortSignal, + }; + +export const mixedOwnershipOptions: LoadRouteQueryLifetimeOptions = + { + ...baseOptions, + // @ts-expect-error The lifetime and raw abort-signal inputs are exclusive. + abortSignal, + lifetime, + }; + +// @ts-expect-error Every route query load requires one ownership input. +export const missingOwnershipOptions: LoadRouteQueryLifetimeOptions = + baseOptions; diff --git a/packages/webapp-graphql-relay/package.json b/packages/webapp-graphql-relay/package.json index a2e71ed..a56f99d 100644 --- a/packages/webapp-graphql-relay/package.json +++ b/packages/webapp-graphql-relay/package.json @@ -45,6 +45,7 @@ "devDependencies": { "@omgjs/labkit-eslint-config": "workspace:*", "@types/node": "^25.6.0", + "@types/react": "^19.2.14", "@types/react-relay": "^18.2.1", "@types/relay-runtime": "^20.1.1", "eslint": "^10.3.0", diff --git a/packages/webapp-graphql-relay/src/index.ts b/packages/webapp-graphql-relay/src/index.ts index 388b58a..b284413 100644 --- a/packages/webapp-graphql-relay/src/index.ts +++ b/packages/webapp-graphql-relay/src/index.ts @@ -20,6 +20,7 @@ import { type LoadQueryOptions, type PreloadedQuery, } from "react-relay"; +import { useEffect, useLayoutEffect } from "react"; import type { RelayObservable } from "relay-runtime/lib/network/RelayObservable"; import type { Client, FormattedExecutionResult, Sink } from "graphql-ws"; import { @@ -152,6 +153,21 @@ export type RouteAbortSignal = { ): void; }; +export type RouteQueryOwnerSignal = RouteAbortSignal & { + readonly reason?: unknown; + removeEventListener(type: "abort", listener: () => void): void; +}; + +export type RouteQueryLifetime = { + readonly abortSignal: RouteAbortSignal; + abort(reason?: unknown): void; + acquire(): () => void; +}; + +export type CreateRouteQueryLifetimeOptions = { + routeAbortSignal: RouteQueryOwnerSignal; +}; + export type RouteQueryLoader = ( environment: Environment, query: GraphQLTaggedNode | PreloadableConcreteRequest, @@ -159,8 +175,7 @@ export type RouteQueryLoader = ( options?: LoadQueryOptions, ) => PreloadedQuery; -export type LoadRouteQueryOptions = { - abortSignal: RouteAbortSignal; +type LoadRouteQueryBaseOptions = { environment: Environment; fetchPolicy?: FetchPolicy | null; loadQuery?: RouteQueryLoader; @@ -170,6 +185,23 @@ export type LoadRouteQueryOptions = { variables: VariablesOf; }; +/** + * @deprecated Mounted route queries must use `LoadRouteQueryLifetimeOptions`. + * Raw abort-signal ownership remains available only for abort-scoped work that + * never becomes a mounted React resource. + */ +export type LoadRouteQueryOptions = + LoadRouteQueryBaseOptions & { + abortSignal: RouteAbortSignal; + lifetime?: never; + }; + +export type LoadRouteQueryLifetimeOptions = + LoadRouteQueryBaseOptions & { + abortSignal?: never; + lifetime: RouteQueryLifetime; + }; + type GlobalWithGraphqlRelayFetch = typeof globalThis & { fetch?: GraphqlRelayFetch; }; @@ -516,9 +548,38 @@ export class DefaultWebappRelayRuntime { }; } +export function loadRouteQuery( + options: LoadRouteQueryLifetimeOptions, +): PreloadedQuery; +/** + * @deprecated Mounted route queries must use the `lifetime` overload. A raw + * signal remains available only for abort-scoped work that never becomes a + * mounted React resource. + */ export function loadRouteQuery( options: LoadRouteQueryOptions, +): PreloadedQuery; +export function loadRouteQuery( + options: + | LoadRouteQueryLifetimeOptions + | LoadRouteQueryOptions, ): PreloadedQuery { + let abortSignal: RouteAbortSignal; + if (options.lifetime !== undefined) { + if (options.abortSignal !== undefined) { + throw new TypeError( + "loadRouteQuery requires exactly one of lifetime or abortSignal", + ); + } + abortSignal = options.lifetime.abortSignal; + } else { + if (options.abortSignal === undefined) { + throw new TypeError( + "loadRouteQuery requires exactly one of lifetime or abortSignal", + ); + } + abortSignal = options.abortSignal; + } const queryRef = (options.loadQuery ?? loadReactRelayQuery)( options.environment, options.query, @@ -539,15 +600,107 @@ export function loadRouteQuery( queryRef.dispose(); }; - if (options.abortSignal.aborted) { + if (abortSignal.aborted) { disposeOnce(); return queryRef; } - options.abortSignal.addEventListener("abort", disposeOnce, { once: true }); + abortSignal.addEventListener("abort", disposeOnce, { once: true }); return queryRef; } +export function createRouteQueryLifetime({ + routeAbortSignal, +}: CreateRouteQueryLifetimeOptions): RouteQueryLifetime { + const queryAbortController = new AbortController(); + let consumerCount = 0; + let routeLeaseActive = true; + let routeAbortReason: unknown; + let routeListenerAttached = false; + let terminal = false; + + const detachRouteListener = () => { + if (!routeListenerAttached) { + return; + } + + routeListenerAttached = false; + routeAbortSignal.removeEventListener("abort", releaseRouteLease); + }; + + const abort = (reason?: unknown) => { + if (terminal) { + return; + } + + terminal = true; + routeLeaseActive = false; + detachRouteListener(); + queryAbortController.abort(reason); + }; + + const abortWhenUnowned = () => { + if (!routeLeaseActive && consumerCount === 0) { + abort(routeAbortReason); + } + }; + + function releaseRouteLease(): void { + if (!routeLeaseActive || terminal) { + return; + } + + routeLeaseActive = false; + routeAbortReason = routeAbortSignal.reason; + detachRouteListener(); + abortWhenUnowned(); + } + + if (routeAbortSignal.aborted) { + routeAbortReason = routeAbortSignal.reason; + routeLeaseActive = false; + abortWhenUnowned(); + } else { + routeListenerAttached = true; + routeAbortSignal.addEventListener("abort", releaseRouteLease, { + once: true, + }); + if (routeAbortSignal.aborted) { + releaseRouteLease(); + } + } + + return { + abortSignal: queryAbortController.signal, + abort, + acquire: () => { + if (terminal) { + throw new Error("Cannot acquire an aborted route query lifetime."); + } + + consumerCount += 1; + let released = false; + return () => { + if (released) { + return; + } + + released = true; + consumerCount -= 1; + abortWhenUnowned(); + }; + }, + }; +} + +export function useRouteQueryLifetime(lifetime: RouteQueryLifetime): void { + // Own the reference from commit until passive ownership is established. + useLayoutEffect(() => lifetime.acquire(), [lifetime]); + // Passive ownership survives Suspense disconnecting layout effects while + // the previous route remains visible. + useEffect(() => lifetime.acquire(), [lifetime]); +} + export function appendRootFieldRecordIfMissing( store: RecordSourceSelectorProxy, rootFieldName: string, diff --git a/packages/webapp-graphql-relay/test/index.test.ts b/packages/webapp-graphql-relay/test/index.test.ts index 8d6a8bb..abcffb9 100644 --- a/packages/webapp-graphql-relay/test/index.test.ts +++ b/packages/webapp-graphql-relay/test/index.test.ts @@ -10,6 +10,7 @@ import type { import type { PreloadedQuery } from "react-relay"; import { appendRootFieldRecordIfMissing, + createRouteQueryLifetime, createAuthAwareRelayGraphqlWsConnectionParams, createAuthAwareRelayFetchFunction, createRelayGraphqlWsConnectionParams, @@ -24,6 +25,11 @@ import { type GraphqlRelayAuthAdapter, type GraphqlRelayFetch, type GraphqlRelayAuthSessionLike, + type LoadRouteQueryLifetimeOptions, + type LoadRouteQueryOptions, + type RouteAbortSignal, + type RouteQueryLifetime, + type RouteQueryOwnerSignal, } from "../src/index"; function createRequest(name: string, text = "query Test { node }") { @@ -67,6 +73,43 @@ type TestRouteQuery = OperationType & { }; }; +class TestRouteQueryOwnerSignal implements RouteQueryOwnerSignal { + public aborted = false; + public addCalls = 0; + public reason: unknown; + public removeCalls = 0; + + private readonly listeners = new Set<() => void>(); + + public addEventListener( + type: "abort", + listener: () => void, + _options?: { once?: boolean }, + ): void { + assert.equal(type, "abort"); + this.addCalls += 1; + this.listeners.add(listener); + } + + public removeEventListener(type: "abort", listener: () => void): void { + assert.equal(type, "abort"); + this.removeCalls += 1; + this.listeners.delete(listener); + } + + public abort(reason?: unknown): void { + if (this.aborted) { + return; + } + + this.aborted = true; + this.reason = reason; + for (const listener of [...this.listeners]) { + listener(); + } + } +} + test("createRelayGraphqlWsConnectionParams returns bearer authorization when a token exists", () => { assert.deepEqual( createRelayGraphqlWsConnectionParams(() => null), @@ -546,7 +589,119 @@ test("DefaultWebappRelayRuntime does not terminate realtime for its own connecti await Promise.resolve(runtime.dispose()); }); -test("loadRouteQuery disposes the query ref once when the route aborts", () => { +test("createRouteQueryLifetime waits for the final consumer after route abort", () => { + const routeAbortSignal = new TestRouteQueryOwnerSignal(); + const lifetime = createRouteQueryLifetime({ routeAbortSignal }); + const releaseFirst = lifetime.acquire(); + const releaseSecond = lifetime.acquire(); + let aborts = 0; + + lifetime.abortSignal.addEventListener("abort", () => { + aborts += 1; + }); + routeAbortSignal.abort("route-replaced"); + + assert.equal(lifetime.abortSignal.aborted, false); + assert.equal(routeAbortSignal.addCalls, 1); + assert.equal(routeAbortSignal.removeCalls, 1); + + releaseFirst(); + releaseFirst(); + assert.equal(lifetime.abortSignal.aborted, false); + + releaseSecond(); + releaseSecond(); + assert.equal(lifetime.abortSignal.aborted, true); + assert.equal(aborts, 1); + assert.equal((lifetime.abortSignal as AbortSignal).reason, "route-replaced"); +}); + +test("createRouteQueryLifetime aborts unmounted work with its route", () => { + const routeAbortSignal = new TestRouteQueryOwnerSignal(); + const lifetime = createRouteQueryLifetime({ routeAbortSignal }); + + routeAbortSignal.abort(new Error("route-cancelled")); + routeAbortSignal.abort(new Error("ignored")); + + assert.equal(lifetime.abortSignal.aborted, true); + assert.equal(routeAbortSignal.removeCalls, 1); + assert.match( + String((lifetime.abortSignal as AbortSignal).reason), + /route-cancelled/u, + ); +}); + +test("createRouteQueryLifetime handles an already-aborted route signal", () => { + const routeAbortSignal = new TestRouteQueryOwnerSignal(); + routeAbortSignal.abort("already-aborted"); + + const lifetime = createRouteQueryLifetime({ routeAbortSignal }); + + assert.equal(lifetime.abortSignal.aborted, true); + assert.equal(routeAbortSignal.addCalls, 0); + assert.equal(routeAbortSignal.removeCalls, 0); + assert.throws( + () => lifetime.acquire(), + /Cannot acquire an aborted route query lifetime/u, + ); +}); + +test("createRouteQueryLifetime terminal abort is immediate and idempotent", () => { + const routeAbortSignal = new TestRouteQueryOwnerSignal(); + const lifetime = createRouteQueryLifetime({ routeAbortSignal }); + const release = lifetime.acquire(); + let aborts = 0; + lifetime.abortSignal.addEventListener("abort", () => { + aborts += 1; + }); + + lifetime.abort("partial-construction-failure"); + lifetime.abort("ignored"); + release(); + release(); + routeAbortSignal.abort("ignored-route-abort"); + + assert.equal(lifetime.abortSignal.aborted, true); + assert.equal(aborts, 1); + assert.equal(routeAbortSignal.removeCalls, 1); + assert.equal( + (lifetime.abortSignal as AbortSignal).reason, + "partial-construction-failure", + ); + assert.throws( + () => lifetime.acquire(), + /Cannot acquire an aborted route query lifetime/u, + ); +}); + +test("loadRouteQuery keeps lifetime-owned work until its final owner releases", () => { + const routeAbortSignal = new TestRouteQueryOwnerSignal(); + const lifetime = createRouteQueryLifetime({ routeAbortSignal }); + const releaseMountedOwner = lifetime.acquire(); + let disposals = 0; + + loadRouteQuery({ + environment: {} as Environment, + lifetime, + loadQuery: () => + ({ + dispose: () => { + disposals += 1; + }, + }) as PreloadedQuery, + query: {} as never, + variables: { id: "1" }, + }); + + routeAbortSignal.abort("route-replaced"); + assert.equal(disposals, 0); + + releaseMountedOwner(); + releaseMountedOwner(); + assert.equal(disposals, 1); +}); + +test("loadRouteQuery retains the deprecated raw abort-signal behavior", () => { const abortController = new AbortController(); let disposals = 0; const queryRef = { @@ -575,7 +730,7 @@ test("loadRouteQuery disposes the query ref once when the route aborts", () => { assert.equal(disposals, 1); }); -test("loadRouteQuery disposes immediately when the route signal is already aborted", () => { +test("loadRouteQuery disposes immediately for an already-aborted raw signal", () => { const abortController = new AbortController(); let disposals = 0; abortController.abort(); @@ -596,7 +751,7 @@ test("loadRouteQuery disposes immediately when the route signal is already abort assert.equal(disposals, 1); }); -test("loadRouteQuery registers one route abort disposal listener", () => { +test("loadRouteQuery registers one raw-signal disposal listener", () => { const listeners: Array<{ listener: () => void; options?: { once?: boolean }; @@ -638,6 +793,44 @@ test("loadRouteQuery registers one route abort disposal listener", () => { assert.equal(disposals, 1); }); +test("loadRouteQuery options accept exactly one ownership input", () => { + const baseOptions = { + environment: {} as Environment, + query: {} as never, + variables: { id: "1" }, + }; + const lifetime = {} as RouteQueryLifetime; + const abortSignal = {} as RouteAbortSignal; + const lifetimeOptions: LoadRouteQueryLifetimeOptions = { + ...baseOptions, + lifetime, + }; + const legacyOptions: LoadRouteQueryOptions = { + ...baseOptions, + abortSignal, + }; + const mixedOptions: LoadRouteQueryLifetimeOptions = { + ...baseOptions, + // @ts-expect-error Lifetime and raw abort-signal ownership are exclusive. + abortSignal, + lifetime, + }; + // @ts-expect-error A lifetime-based query load requires its ownership input. + const missingOptions: LoadRouteQueryLifetimeOptions = + baseOptions; + + assert.equal(lifetimeOptions.lifetime, lifetime); + assert.equal(legacyOptions.abortSignal, abortSignal); + assert.throws( + () => loadRouteQuery(mixedOptions), + /requires exactly one of lifetime or abortSignal/u, + ); + assert.throws( + () => loadRouteQuery(missingOptions), + /requires exactly one of lifetime or abortSignal/u, + ); +}); + test("appendRootFieldRecordIfMissing appends unique root records", () => { const records = [{ getDataID: () => "existing" }]; const incoming = { getDataID: () => "incoming" }; diff --git a/rush.json b/rush.json index 0533a36..565422e 100644 --- a/rush.json +++ b/rush.json @@ -94,6 +94,11 @@ "projectFolder": "tools/eslint-config", "reviewCategory": "tools", "versionPolicyName": "labkit" + }, + { + "packageName": "@omgjs/labkit-webapp-graphql-relay-lifecycle-fixture", + "projectFolder": "tools/webapp-graphql-relay-lifecycle-fixture", + "reviewCategory": "tools" } ] } diff --git a/tasks/2026-08-11-1124_HARDEN_TANSTACK_RELAY_ROUTE_LIFECYCLES.md b/tasks/2026-08-11-1124_HARDEN_TANSTACK_RELAY_ROUTE_LIFECYCLES.md index 9a56515..ef923fc 100644 --- a/tasks/2026-08-11-1124_HARDEN_TANSTACK_RELAY_ROUTE_LIFECYCLES.md +++ b/tasks/2026-08-11-1124_HARDEN_TANSTACK_RELAY_ROUTE_LIFECYCLES.md @@ -1,14 +1,15 @@ # Harden TanStack Router And Relay Route-Query Lifecycles -Status: planned; begin with characterization and a public-contract decision -gate. +Status: implementation and validation complete; awaiting commit/PR and the +post-merge release workflow. Created: 2026-08-11 -Classification: integration hardening and compatibility improvement. It is not -a confirmed library bug at task creation. +Classification: confirmed lifecycle integration bug whose smallest reusable +fix is an additive public API improvement. Prerequisite: -[Fix Relay Peer Runtime Contract](./2026-08-11-1124_FIX_RELAY_PEER_RUNTIME_CONTRACT.md). -Release guidance: determine after the contract gate; tests alone do not require -a public package release. +[Fix Relay Peer Runtime Contract](./completed/2026-08-11-1124_FIX_RELAY_PEER_RUNTIME_CONTRACT.md). +Release guidance: proposed minor release of +`@omgjs/labkit-webapp-graphql-relay` (3.0.0 to 3.1.0) if Gate B approves the +additive API. Do not edit the version manually. ## Decision Summary @@ -23,7 +24,7 @@ helper and a documented application-owned route recipe, stop at the public API gate and obtain maintainer approval before adding exports, changing signatures, or introducing a public TanStack dependency/package. -This task is reclassified only from evidence: +The initial classification rules were: - it is a library bug if the documented helper fails in an explicitly supported, single-runtime configuration; @@ -32,7 +33,9 @@ This task is reclassified only from evidence: - it becomes an API feature/change if a new lifecycle controller, ownership handle, or router adapter is required. -Do not choose a Rush bump type until that outcome is recorded. +Phase 1 evidence below confirms the first and third cases: the supported helper +can dispose Relay work while its mounted consumer remains visible, and fixing +that ownership mismatch requires a reusable lifetime primitive. ## Prerequisite And Baseline @@ -258,34 +261,34 @@ rejections, work outside `act`, or open handles after teardown. Do not generate a full Cartesian product. Implement every distinct transition below and apply alternate completion order/Strict Mode only where specified. -| ID | Scenario | Required outcome | -| --- | --- | --- | -| B1 | Direct initial entry; one query suspends then succeeds | Correct data renders; the reference stays live while mounted | -| B2 | Full browser reload on the single-query URL | A fresh runtime/reference renders the same correct identity without warmed state | -| B3 | Signal already aborted and repeated abort notifications | The reference never renders and disposal is idempotent | -| N1 | Rendered A navigates to B while B is pending | A remains usable while mounted; B commits correctly; A is eventually released | -| N2 | Pending A is superseded by B | A is canceled/released; a late A response cannot commit | -| N3 | Rapid A(1) -> A(2) -> B with out-of-order responses | Only the final match commits; all superseded work is released | -| N4 | Route param or search dependency changes | Old/new identities cannot be confused and replacement follows the selected policy | -| H1 | A -> B -> browser back inside the fresh-cache window | Back reuses a proven-live reference or reloads before render, exactly as documented | -| H2 | Browser forward after H1 | Forward follows the same ownership/cache policy | -| H3 | Return after cache expiry or explicit clear | A new reference is created; an expired/released reference is never reused | -| H4 | Supported stale revalidation | Active data remains usable until replacement commits; no early release occurs | -| P1 | Pending preload is promoted by navigation | The documented promotion/deduplication behavior occurs without early release | -| P2 | Completed preload is promoted | Preloaded data renders under the documented freshness policy | -| P3 | Preload is canceled or expires unused | It never renders and is eventually released | -| R1 | Query failure, route error, then retry succeeds | Retry creates valid new work; failed work is not reused or leaked | -| R2 | Active route invalidation/replacement | The current route follows the documented pending/replacement policy safely | -| R3 | Retry/invalidation is superseded by navigation | Superseded retry work cannot commit and is released | -| M1 | Two queries, first completes before second | Both results render; sibling completion does not release the other reference | -| M2 | Two queries, second completes before first | Ownership and final rendering match M1 | -| M3 | One query completes, then the route is abandoned while its sibling is pending | Both route-owned references are eventually released; pending work is canceled as supported | -| M4 | One of two queries fails, then retry succeeds | No sibling leak or reuse of failed/released work | -| M5 | Partial multi-query construction throws | Every already-created reference is cleaned up according to the approved contract | -| S1 | B1 under React Strict Mode | No duplicate loader ownership, premature disposal, warning, or failure | -| S2 | N1 and one multi-query transition under Strict Mode | Mount probing cannot release work still required by active UI | -| T1 | Router/provider/root teardown | All integration-owned references, requests, listeners, and subscriptions are released | -| T2 | Runtime/router recreated at the same location | No reference or store identity crosses from the old runtime | +| ID | Scenario | Required outcome | +| --- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| B1 | Direct initial entry; one query suspends then succeeds | Correct data renders; the reference stays live while mounted | +| B2 | Full browser reload on the single-query URL | A fresh runtime/reference renders the same correct identity without warmed state | +| B3 | Signal already aborted and repeated abort notifications | The reference never renders and disposal is idempotent | +| N1 | Rendered A navigates to B while B is pending | A remains usable while mounted; B commits correctly; A is eventually released | +| N2 | Pending A is superseded by B | A is canceled/released; a late A response cannot commit | +| N3 | Rapid A(1) -> A(2) -> B with out-of-order responses | Only the final match commits; all superseded work is released | +| N4 | Route param or search dependency changes | Old/new identities cannot be confused and replacement follows the selected policy | +| H1 | A -> B -> browser back inside the fresh-cache window | Back reuses a proven-live reference or reloads before render, exactly as documented | +| H2 | Browser forward after H1 | Forward follows the same ownership/cache policy | +| H3 | Return after cache expiry or explicit clear | A new reference is created; an expired/released reference is never reused | +| H4 | Supported stale revalidation | Active data remains usable until replacement commits; no early release occurs | +| P1 | Pending preload is promoted by navigation | The documented promotion/deduplication behavior occurs without early release | +| P2 | Completed preload is promoted | Preloaded data renders under the documented freshness policy | +| P3 | Preload is canceled or expires unused | It never renders and is eventually released | +| R1 | Query failure, route error, then retry succeeds | Retry creates valid new work; failed work is not reused or leaked | +| R2 | Active route invalidation/replacement | The current route follows the documented pending/replacement policy safely | +| R3 | Retry/invalidation is superseded by navigation | Superseded retry work cannot commit and is released | +| M1 | Two queries, first completes before second | Both results render; sibling completion does not release the other reference | +| M2 | Two queries, second completes before first | Ownership and final rendering match M1 | +| M3 | One query completes, then the route is abandoned while its sibling is pending | Both route-owned references are eventually released; pending work is canceled as supported | +| M4 | One of two queries fails, then retry succeeds | No sibling leak or reuse of failed/released work | +| M5 | Partial multi-query construction throws | Every already-created reference is cleaned up according to the approved contract | +| S1 | B1 under React Strict Mode | No duplicate loader ownership, premature disposal, warning, or failure | +| S2 | N1 and one multi-query transition under Strict Mode | Mount probing cannot release work still required by active UI | +| T1 | Router/provider/root teardown | All integration-owned references, requests, listeners, and subscriptions are released | +| T2 | Runtime/router recreated at the same location | No reference or store identity crosses from the old runtime | For history and stale-revalidation cases, choose and document one coherent strategy: @@ -296,30 +299,261 @@ strategy: Do not accept accidental reload behavior or an arbitrary grace timeout as the contract. +## Evidence And Decisions + +### Characterization Baseline + +Phase 1 used a private Rush fixture with Node 24.18.0, React/React DOM 19.2.6, +React Relay/Relay Runtime/Relay Compiler 20.1.1, TanStack React Router 1.170.25 +(Router Core 1.171.21), TypeScript 6.0.3, Vitest 4.1.10, jsdom 30.0.1, Vite +8.2.1, and Playwright 1.62.1. + +The fixture imports the public Labkit package root and uses a real +`RouterProvider`, `RelayEnvironmentProvider`, generated operations, +`usePreloadedQuery`, controlled Relay network Observables, and an event ledger. +Its preflight proves that fixture code, Labkit, React Relay, and Relay Runtime +resolve the exact supported 20.1.1 pair through one canonical runtime path. A +negative preflight test rejects a duplicate runtime path. + +The official contracts used for characterization are: + +- TanStack assigns `abortController.signal` to a loader invocation. The signal + aborts when that invocation becomes outdated and no Router consumer needs + it. Navigation and preload may share an invocation. +- TanStack navigation `staleTime` defaults to zero, preloads have separate + freshness, and cached matches have garbage-collection behavior. Cache + clearing does not replace active match ownership. +- Relay retains a query reference until `dispose()`; after disposal it is no + longer a valid mounted-consumer resource. Network completion and reference + ownership are separate. + +The selected safe router policy is blocking route replacement with an explicit +pending component and `gcTime: 0`. Loader values containing query references +must not be reused after release. Preload freshness remains explicit, and final +teardown navigates to a query-free route, clears cache, and unmounts. + +### Observed Results + +The existing helper passes direct entry, already-aborted loading, safe history +reload under the selected no-reuse policy, preload promotion, retry, both +two-query completion orders, and final teardown. + +It fails N1 under a normal, single-runtime supported graph: + +1. route A creates reference A, resolves, renders, and mounts; +2. route B starts and suspends on pending reference B; +3. TanStack keeps the mounted A UI visible while B is pending; +4. TanStack retires A's loader invocation and aborts A's loader signal; +5. `loadRouteQuery` immediately disposes reference A; +6. A remains mounted and visible with a disposed reference. + +The Strict Mode S2 pair reproduces the same mismatch for two references: both +are released when replacement starts while their mounted route remains the +visible UI. This is not a duplicate-runtime, network-order, timer, cache, or +test-`act` artifact. The minimized invariant is that a loader invocation's +lifetime is shorter than the mounted React consumer's lifetime. + +Partial multi-query construction exposes a second ownership gap. If the first +reference is created and construction of the second throws, TanStack does not +immediately abort the loader signal, so the first reference leaks. An explicit +terminal abort of a shared child lifetime fixes this path but does not fix N1; +the mounted consumer must also hold a lease. + +The characterization suite intentionally retains failing N1 and S2 assertions +at Gate B. It must not be wired into required CI until the approved ownership +contract makes those invariants pass. + +### Gate A Decision + +The existing `loadRouteQuery` abort behavior plus an application-only cache +recipe is insufficient. A loader signal cannot represent both a TanStack +loader invocation and one or more mounted React consumers. Keeping released +references in Router cache, delaying disposal with a timeout, or changing fetch +policy cannot make those ownership domains identical. + +TanStack remains application-owned, but reusable Relay reference lifetime +mechanics belong in the Relay library. The fixture may translate TanStack +events into a router-agnostic library lifetime; the library must not import +TanStack types or runtime code. + +### Gate B Proposal - Approval Required + +Add the following router-agnostic API to +`@omgjs/labkit-webapp-graphql-relay`: + +```ts +export type RouteQueryOwnerSignal = RouteAbortSignal & { + readonly reason?: unknown; + removeEventListener(type: "abort", listener: () => void): void; +}; + +export type RouteQueryLifetime = { + readonly abortSignal: RouteAbortSignal; + abort(reason?: unknown): void; + acquire(): () => void; +}; + +export function createRouteQueryLifetime(options: { + routeAbortSignal: RouteQueryOwnerSignal; +}): RouteQueryLifetime; + +export function useRouteQueryLifetime(lifetime: RouteQueryLifetime): void; + +// The existing environment/query/variables/fetch options are unchanged. +export type LoadRouteQueryLifetimeOptions = + LoadRouteQueryBaseOptions & { + lifetime: RouteQueryLifetime; + abortSignal?: never; + }; + +/** @deprecated Use LoadRouteQueryLifetimeOptions for mounted route queries. */ +export type LoadRouteQueryOptions = + LoadRouteQueryBaseOptions & { + abortSignal: RouteAbortSignal; + lifetime?: never; + }; +``` + +The ownership state is: + +```text +route lease + zero mounted leases + | + +-- acquire --> route lease + mounted lease(s) + | +route abort --+--> mounted lease(s) remain --> final mounted release --> abort + | + +--> no mounted lease -------------------------------> abort +explicit terminal abort -------------------------------------------> abort +``` + +The lifetime is passed unchanged to one or more calls to `loadRouteQuery`; its +child `abortSignal` remains available for other abort-aware resources. The +parent route signal releases only the route lease. The +hook acquires complementary commit-time and passive mounted leases. The layout +lease covers the interval before passive effects run; the passive lease +survives React Suspense disconnecting layout effects while the previous UI +remains visible. Both release during their corresponding cleanup. The child +signal aborts exactly once after the route lease and final mounted lease are +gone, or immediately after explicit terminal `abort()`. Listeners are detached +at terminal release. Acquisition after terminal release is rejected. Every +returned release callback and `abort()` is idempotent. + +Responsibilities are: + +- the loader creates one lifetime per invocation and passes that lifetime to + every query reference created by that loader; +- on partial construction failure, the loader calls terminal `abort()` before + rethrowing; +- the route component calls `useRouteQueryLifetime` for the returned lifetime; +- the application owns route cache policy, pending UI, retry, history, and + final router teardown; +- the library owns lease accounting, terminal abort, listener cleanup, and + Strict Mode-safe mounted ownership; +- one lifetime may own one reference or a group of independently completing + references; completion never releases ownership by itself. + +`loadRouteQuery` remains source-compatible for valid existing calls. Its safe, +documented branch accepts `lifetime`; the raw `abortSignal` branch remains +available but is deprecated for abort-scoped work guaranteed never to mount. +The ownership inputs are mutually exclusive in declarations and at runtime. +TanStack route loaders with mounted consumers create a lifetime, pass it into +the helper, return it with their refs, and mount the hook. + +This adds no TanStack dependency or peer. React is already a peer. The public +package's CJS, ESM, and declaration surfaces must export the new API and pass +packed-consumer validation. The proposed change is additive and therefore a +minor Rush change for only this package, with an expected version of 3.1.0. + +Rejected alternatives: + +- use the TanStack loader signal directly: proven to dispose mounted work; +- retain loader values in cache: can make a disposed reference reusable; +- add a timeout or grace period: nondeterministic and has no ownership event; +- copy a controller into every application: duplicates reusable Relay + lifecycle mechanics and leaves the documented helper unsafe to compose; +- add a TanStack adapter package: no router-specific public type or event is + required by the proposed primitive; +- change `loadRouteQuery` semantics: would be breaking and would overload one + abort signal with ownership it cannot observe. + +Maintainer approval of this proposal was granted on 2026-08-11. Phase 2 may +proceed with option 3 exactly as proposed. + +### Approved Implementation Result + +The public package now implements the approved router-independent lifetime and +adds no TanStack dependency. Before commit, the maintainer approved a +lifetime-first refinement: `loadRouteQuery({ lifetime })` is the safe branch, +the raw `abortSignal` branch is deprecated compatibility for work guaranteed +never to mount, and supplying both or neither ownership input is rejected. The +existing exported raw-signal options type is preserved, while a separate +`LoadRouteQueryLifetimeOptions` type and lifetime-first function overload keep +the change genuinely additive/minor. Valid existing raw-signal calls and +wrappers continue to compile and retain their behavior. Unit coverage proves +route/consumer ownership, multiple consumers, already-aborted signals, listener +detachment, terminal partial-construction abort, idempotency, exclusive +ownership inputs, and rejection of acquisition after terminal release. + +Real React characterization refined the approved hook implementation. A +layout-only lease is insufficient because Suspense can disconnect layout +effects while keeping the previous route visible. The final hook holds one +commit-time layout lease and one passive mounted lease. The first closes the +pre-passive commit gap; the second survives that Suspense disconnection until +real unmount. N1 and Strict Mode S2 pass with this complementary ownership. + +Permanent validation completed with: + +- 25 public-package unit tests, including lifetime-first runtime behavior, + deprecated raw-signal behavior, and compile/runtime ownership exclusivity; +- 27 deterministic real TanStack/React/Relay/jsdom lifecycle tests covering + every matrix row; +- one real Chromium/Vite test covering fresh direct entry, pending + replacement, document reload, browser back/forward, runtime recreation, and + final teardown; +- a publish-equivalent isolated 3.1 tarball matrix covering supported npm and + isolated pnpm installs, strict peers, negative peer graphs, declarations, + CJS, native and bundled ESM, Vite, SSR/provider rendering, and one canonical + Relay runtime; +- full Rush build, lint, test, and verify; +- Docusaurus source synchronization, typecheck, and production build. + +The customer migration is documented in +[Upgrade Webapp GraphQL Relay To +3.1](../docs/upgrades/webapp-graphql-relay-3.1.md) and registered in the +Docusaurus docs tree. Package README/reference, GraphQL contract, webapp +composition, and architecture sources describe the same ownership boundary. + +`trunk check -a -y` was executed. Task-owned formatting was retained and +out-of-scope autofixes were reverted. Remaining findings are repository-wide +pre-existing Docker/action-pin, generated changelog Markdown, and dependency +advisory findings; none points to the new public source, fixture source, or +customer documentation. + ## Work Plan ### Phase 1 - Upstream Contract And Characterization Make no public source changes in this phase. -- [ ] Read `AGENTS.md`, `.ai/conventions.md`, `.ai/architecture.md`, and the +- [x] Read `AGENTS.md`, `.ai/conventions.md`, `.ai/architecture.md`, and the task-specific task-file, documentation, and library-release rules before implementation. -- [ ] Read current official TanStack and Relay documentation for the pinned +- [x] Read current official TanStack and Relay documentation for the pinned versions. -- [ ] Inspect `git status --short` and preserve unrelated changes. -- [ ] Complete the one-runtime preflight. -- [ ] Record current `loadRouteQuery` behavior and existing unit coverage. -- [ ] Create the private Rush fixture, neutral schema/operations, controlled +- [x] Inspect `git status --short` and preserve unrelated changes. +- [x] Complete the one-runtime preflight. +- [x] Record current `loadRouteQuery` behavior and existing unit coverage. +- [x] Create the private Rush fixture, neutral schema/operations, controlled network, and lifecycle ledger. -- [ ] Characterize at least B1, N1, N2, H1, P1, R1, M1, M2, and T1 against the +- [x] Characterize at least B1, N1, N2, H1, P1, R1, M1, M2, and T1 against the existing public helper. -- [ ] Minimize each failure and distinguish Labkit behavior, fixture mistakes, +- [x] Minimize each failure and distinguish Labkit behavior, fixture mistakes, documented TanStack behavior, unsupported configuration, and upstream defects. -- [ ] Add an `Evidence And Decisions` section to this file with observed event +- [x] Add an `Evidence And Decisions` section to this file with observed event order and exact tested versions. -- [ ] Do not land assertions that merely freeze accidental current behavior. +- [x] Do not land assertions that merely freeze accidental current behavior. Phase 1 is complete when the current ownership boundary is measured rather than assumed. @@ -371,37 +605,37 @@ package before approval is recorded. After Gate A or Gate B is resolved: -- [ ] Implement only the approved ownership model. -- [ ] Keep the core Relay package router-agnostic whenever possible. -- [ ] Keep disposal idempotent and detach listeners when ownership transfer +- [x] Implement only the approved ownership model. +- [x] Keep the core Relay package router-agnostic whenever possible. +- [x] Keep disposal idempotent and detach listeners when ownership transfer makes them obsolete. -- [ ] Never call `loadQuery` during React render. -- [ ] Do not read, clone, or serialize private query-reference fields. -- [ ] Treat multiple references as independent resources. -- [ ] Clean up partial construction failures. -- [ ] Preserve existing fetch policy and network-cache options unless an +- [x] Never call `loadQuery` during React render. +- [x] Do not read, clone, or serialize private query-reference fields. +- [x] Treat multiple references as independent resources. +- [x] Clean up partial construction failures. +- [x] Preserve existing fetch policy and network-cache options unless an approved public proposal says otherwise. -- [ ] Add focused unit/state-machine tests before expanding the integration +- [x] Add focused unit/state-machine tests before expanding the integration suite. -- [ ] Preserve a safe compatibility path if a new additive API is approved. +- [x] Preserve a safe compatibility path if a new additive API is approved. Phase 2 is complete when the minimal approved contract is implemented and its unit-level ownership transitions pass. ### Phase 3 - Complete The Integration And Browser Matrices -- [ ] Implement every required matrix row. -- [ ] Use controlled response order rather than race-prone sleeps. -- [ ] Assert route and rendered data identity after every navigation. -- [ ] Assert late responses cannot alter the selected final UI. -- [ ] Assert exact disposal at controlled contract seams and public +- [x] Implement every required matrix row. +- [x] Use controlled response order rather than race-prone sleeps. +- [x] Assert route and rendered data identity after every navigation. +- [x] Assert late responses cannot alter the selected final UI. +- [x] Assert exact disposal at controlled contract seams and public cancellation/teardown outcomes in real Relay cases. -- [ ] Execute both multi-query completion orders. -- [ ] Execute selected transitions under Strict Mode. -- [ ] Add the minimal Vite/browser subset for direct entry, reload, history, +- [x] Execute both multi-query completion orders. +- [x] Execute selected transitions under Strict Mode. +- [x] Add the minimal Vite/browser subset for direct entry, reload, history, and final teardown. -- [ ] Give every test a fresh runtime and complete cleanup. -- [ ] Remove temporary diagnostic logging after ledger assertions stabilize. +- [x] Give every test a fresh runtime and complete cleanup. +- [x] Remove temporary diagnostic logging after ledger assertions stabilize. If a documented upstream behavior prevents a required invariant, record a minimal reproduction and stop rather than hiding the behavior with a delay, @@ -411,20 +645,25 @@ retry loop, or cache workaround. After the ownership decision, update only affected current sources: -- [ ] [Package README](../packages/webapp-graphql-relay/README.md). -- [ ] [Package reference](../docs/packages/webapp-graphql-relay.md). -- [ ] [GraphQL contract](../docs/graphql-contract.md), if the general query +- [x] [Package README](../packages/webapp-graphql-relay/README.md). +- [x] [Package reference](../docs/packages/webapp-graphql-relay.md). +- [x] [GraphQL contract](../docs/graphql-contract.md), if the general query ownership contract changes. -- [ ] [Webapp composition](../docs/webapp-composition.md). -- [ ] [Architecture](../docs/architecture.md), only if the Labkit/application +- [x] [Webapp composition](../docs/webapp-composition.md). +- [x] [Architecture](../docs/architecture.md), only if the Labkit/application ownership boundary changes or needs a reusable rule. +- [x] Add a customer-facing 3.1 upgrade guide to the current `docs` source and + Docusaurus navigation. Cover migration from passing the TanStack loader + signal directly, single- and multi-query examples, supported cache and + pending-UI policy, retry/error cleanup, Strict Mode, and final teardown. Document exact tested versions, supported cache/revalidation assumptions, initial/preload/navigation/history/retry behavior, multi-query composition, final release rules, app-owned responsibilities, and migration if applicable. Do not present fixture route files or generated operations as Labkit-owned. -Do not hand-edit `website-docusaurus/docs` or `docs-versions`. +Do not hand-edit generated `website-docusaurus/docs` or `docs-versions`; the +upgrade guide must live in the current source consumed by Docusaurus. ### Phase 5 - Release And CI Decision @@ -438,15 +677,15 @@ Choose release metadata from the final diff: - incompatible signature, dependency, or behavior change: major; - new public adapter package: separate approved package/release design. -- [ ] Register the private tool project and its build ordering intentionally in +- [x] Register the private tool project and its build ordering intentionally in `rush.json`. -- [ ] Make fast deterministic tests part of Rush test/verify. -- [ ] Add a dedicated browser command/workflow step only if the browser layer +- [x] Make fast deterministic tests part of Rush test/verify. +- [x] Add a dedicated browser command/workflow step only if the browser layer cannot run safely inside the existing job. -- [ ] Create Rush change metadata only for packages whose released contract +- [x] Create Rush change metadata only for packages whose released contract changes. -- [ ] Do not manually edit package versions or changelogs. -- [ ] Leave publication to post-merge CI. +- [x] Do not manually edit package versions or changelogs. +- [x] Leave publication to post-merge CI. ## Validation @@ -474,42 +713,44 @@ trunk check -a -y ``` The permanent matrix must have no arbitrary sleeps, random completion order, -shared runtime between cases, expected console warnings, network dependency, or -open handles. Report unavailable commands or unrelated failures precisely. +shared runtime between cases, network dependency, or open handles. Intentional +route-error cases may capture only TanStack's exact development-mode route +boundary warning; every other console message fails the test. Report +unavailable commands or unrelated failures precisely. ## Acceptance Criteria -- [ ] Exact tested framework/compiler/tool versions and upstream lifecycle +- [x] Exact tested framework/compiler/tool versions and upstream lifecycle meanings are recorded. -- [ ] The fixture rejects an unsupported or duplicated Relay graph before +- [x] The fixture rejects an unsupported or duplicated Relay graph before lifecycle assertions. -- [ ] A private non-published Rush fixture exercises the package root. -- [ ] TanStack remains private test tooling unless a public boundary is +- [x] A private non-published Rush fixture exercises the package root. +- [x] TanStack remains private test tooling unless a public boundary is explicitly approved. -- [ ] A real `RouterProvider`, `RelayEnvironmentProvider`, generated operation, +- [x] A real `RouterProvider`, `RelayEnvironmentProvider`, generated operation, default `loadRouteQuery`, and `usePreloadedQuery` path render correct data. -- [ ] The lifecycle ledger deterministically records creation, ownership, +- [x] The lifecycle ledger deterministically records creation, ownership, network, routing, rendering, retry, and cleanup transitions. -- [ ] Every required matrix row passes under the approved ownership policy. -- [ ] Fresh direct entry and actual document reload do not depend on warmed +- [x] Every required matrix row passes under the approved ownership policy. +- [x] Fresh direct entry and actual document reload do not depend on warmed Relay or router state. -- [ ] Active or cached routes never observe a released reference. -- [ ] Superseded work cannot commit after a late response. -- [ ] Back/forward and stale revalidation follow one explicit cache policy. -- [ ] Unused preloads and evicted matches eventually release owned work. -- [ ] Both multi-query completion orders and partial-failure paths are safe. -- [ ] Strict Mode introduces no duplicate ownership or premature release. -- [ ] Final teardown leaves no owned references, requests, listeners, +- [x] Active or cached routes never observe a released reference. +- [x] Superseded work cannot commit after a late response. +- [x] Back/forward and stale revalidation follow one explicit cache policy. +- [x] Unused preloads and evicted matches eventually release owned work. +- [x] Both multi-query completion orders and partial-failure paths are safe. +- [x] Strict Mode introduces no duplicate ownership or premature release. +- [x] Final teardown leaves no owned references, requests, listeners, subscriptions, console errors, or open handles. -- [ ] No test relies on private Relay fields, undocumented router internals, or +- [x] No test relies on private Relay fields, undocumented router internals, or wall-clock timing. -- [ ] Gate A evidence is recorded, and Gate B approval exists before any public +- [x] Gate A evidence is recorded, and Gate B approval exists before any public API/dependency/package change. -- [ ] Package/root docs describe the final supported contract and app-owned +- [x] Package/root docs describe the final supported contract and app-owned responsibilities. -- [ ] Rush and CI wiring execute the permanent matrix deterministically. -- [ ] Release metadata matches only the actual public impact. -- [ ] Focused, full Rush, docs, browser, and Trunk validation pass or every gap +- [x] Rush and CI wiring execute the permanent matrix deterministically. +- [x] Release metadata matches only the actual public impact. +- [x] Focused, full Rush, docs, browser, and Trunk validation pass or every gap is reported. - [ ] This file is moved to `tasks/completed` only when every applicable item is complete. diff --git a/tools/webapp-graphql-relay-lifecycle-fixture/.gitignore b/tools/webapp-graphql-relay-lifecycle-fixture/.gitignore new file mode 100644 index 0000000..cb98e73 --- /dev/null +++ b/tools/webapp-graphql-relay-lifecycle-fixture/.gitignore @@ -0,0 +1,3 @@ +/dist-browser/ +/playwright-report/ +/test-results/ diff --git a/tools/webapp-graphql-relay-lifecycle-fixture/browser-test/lifecycle.spec.ts b/tools/webapp-graphql-relay-lifecycle-fixture/browser-test/lifecycle.spec.ts new file mode 100644 index 0000000..d5a2d04 --- /dev/null +++ b/tools/webapp-graphql-relay-lifecycle-fixture/browser-test/lifecycle.spec.ts @@ -0,0 +1,117 @@ +import { expect, test, type Page } from "@playwright/test"; +import type { LifecycleEvent } from "../src/ledger.js"; + +async function resolveRequest( + page: Page, + itemId: string, + label: string, +): Promise { + await expect + .poll(() => + page.evaluate( + ({ nextItemId, nextLabel }) => + window.lifecycleFixture.resolve( + "LifecycleItemQuery", + nextItemId, + nextLabel, + ), + { nextItemId: itemId, nextLabel: label }, + ), + ) + .toBe(true); +} + +async function lifecycleEvents(page: Page): Promise { + return page.evaluate(() => window.lifecycleFixture.events()); +} + +test("direct entry, pending replacement, reload, history, and teardown", async ({ + page, +}) => { + const browserErrors: string[] = []; + page.on("console", (message) => { + if (message.type() === "error" || message.type() === "warning") { + browserErrors.push(`${message.type()}: ${message.text()}`); + } + }); + page.on("pageerror", (error) => { + browserErrors.push(`pageerror: ${error.message}`); + }); + + await page.goto("/items/browser-a"); + const firstRuntimeId = await page.evaluate( + () => window.lifecycleFixture.runtimeId, + ); + await resolveRequest(page, "browser-a", "Browser A"); + await expect(page.getByTestId("item")).toHaveText("Browser A"); + + await page.evaluate(() => { + window.lifecycleFixture.beginNavigation("/items/browser-b"); + }); + await expect + .poll(() => + page.evaluate( + () => + window.lifecycleFixture + .events() + .filter( + (event) => + event.kind === "reference-create" && + event.details.itemId === "browser-b", + ).length, + ), + ) + .toBe(1); + await expect(page.getByTestId("item")).toHaveText("Browser A"); + expect( + (await lifecycleEvents(page)).filter( + (event) => + event.kind === "reference-release" && + event.details.itemId === "browser-a", + ), + ).toHaveLength(0); + + await resolveRequest(page, "browser-b", "Browser B"); + await page.evaluate(() => window.lifecycleFixture.finishNavigation()); + await expect(page.getByTestId("item")).toHaveText("Browser B"); + expect( + (await lifecycleEvents(page)).filter( + (event) => + event.kind === "reference-release" && + event.details.itemId === "browser-a", + ), + ).toHaveLength(1); + + await page.reload(); + const secondRuntimeId = await page.evaluate( + () => window.lifecycleFixture.runtimeId, + ); + expect(secondRuntimeId).not.toBe(firstRuntimeId); + await resolveRequest(page, "browser-b", "Reloaded B"); + await expect(page.getByTestId("item")).toHaveText("Reloaded B"); + + await page.evaluate(() => { + window.lifecycleFixture.beginNavigation("/items/browser-c"); + }); + await resolveRequest(page, "browser-c", "Browser C"); + await page.evaluate(() => window.lifecycleFixture.finishNavigation()); + await expect(page.getByTestId("item")).toHaveText("Browser C"); + + await page.evaluate(() => window.lifecycleFixture.historyBack()); + await resolveRequest(page, "browser-b", "History B"); + await expect(page.getByTestId("item")).toHaveText("History B"); + + await page.evaluate(() => window.lifecycleFixture.historyForward()); + await resolveRequest(page, "browser-c", "History C"); + await expect(page.getByTestId("item")).toHaveText("History C"); + + const finalEvents = await page.evaluate(() => + window.lifecycleFixture.teardown(), + ); + expect( + finalEvents.filter((event) => event.kind === "reference-release"), + ).toHaveLength( + finalEvents.filter((event) => event.kind === "reference-create").length, + ); + expect(browserErrors).toEqual([]); +}); diff --git a/tools/webapp-graphql-relay-lifecycle-fixture/eslint.config.mjs b/tools/webapp-graphql-relay-lifecycle-fixture/eslint.config.mjs new file mode 100644 index 0000000..1e1f331 --- /dev/null +++ b/tools/webapp-graphql-relay-lifecycle-fixture/eslint.config.mjs @@ -0,0 +1,16 @@ +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createNodeBrowserTypeScriptConfig } from "@omgjs/labkit-eslint-config"; + +const tsconfigRootDir = dirname(fileURLToPath(import.meta.url)); + +export default createNodeBrowserTypeScriptConfig({ + extraIgnores: [ + "dist-browser/**", + "playwright-report/**", + "test-results/**", + "vite.config.mts", + "vitest.config.mts", + ], + tsconfigRootDir, +}); diff --git a/tools/webapp-graphql-relay-lifecycle-fixture/index.html b/tools/webapp-graphql-relay-lifecycle-fixture/index.html new file mode 100644 index 0000000..4658502 --- /dev/null +++ b/tools/webapp-graphql-relay-lifecycle-fixture/index.html @@ -0,0 +1,12 @@ + + + + + + Relay lifecycle fixture + + +
+ + + diff --git a/tools/webapp-graphql-relay-lifecycle-fixture/package.json b/tools/webapp-graphql-relay-lifecycle-fixture/package.json new file mode 100644 index 0000000..6b60537 --- /dev/null +++ b/tools/webapp-graphql-relay-lifecycle-fixture/package.json @@ -0,0 +1,39 @@ +{ + "name": "@omgjs/labkit-webapp-graphql-relay-lifecycle-fixture", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "relay-compiler --validate && npm run typecheck && vite build", + "lint": "eslint .", + "relay": "relay-compiler", + "test": "relay-compiler --validate && vitest run", + "test:browser": "playwright test --config playwright.config.ts", + "typecheck": "tsc -p tsconfig.json --noEmit", + "verify": "npm run test" + }, + "dependencies": { + "@omgjs/labkit-webapp-graphql-relay": "workspace:*", + "@tanstack/react-router": "1.170.25", + "react": "19.2.6", + "react-dom": "19.2.6", + "react-relay": "20.1.1", + "relay-runtime": "20.1.1" + }, + "devDependencies": { + "@omgjs/labkit-eslint-config": "workspace:*", + "@playwright/test": "1.62.1", + "@types/jsdom": "30.0.0", + "@types/node": "25.6.0", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.4", + "@types/react-relay": "18.2.1", + "@types/relay-runtime": "20.1.1", + "eslint": "10.3.0", + "jsdom": "30.0.1", + "relay-compiler": "20.1.1", + "typescript": "6.0.3", + "vite": "8.2.1", + "vitest": "4.1.10" + } +} diff --git a/tools/webapp-graphql-relay-lifecycle-fixture/playwright.config.ts b/tools/webapp-graphql-relay-lifecycle-fixture/playwright.config.ts new file mode 100644 index 0000000..b7a3818 --- /dev/null +++ b/tools/webapp-graphql-relay-lifecycle-fixture/playwright.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./browser-test", + use: { + baseURL: "http://127.0.0.1:4173", + browserName: "chromium", + }, + webServer: { + command: "vite preview --host 127.0.0.1 --port 4173", + port: 4173, + reuseExistingServer: false, + }, +}); diff --git a/tools/webapp-graphql-relay-lifecycle-fixture/relay.config.json b/tools/webapp-graphql-relay-lifecycle-fixture/relay.config.json new file mode 100644 index 0000000..e77fe44 --- /dev/null +++ b/tools/webapp-graphql-relay-lifecycle-fixture/relay.config.json @@ -0,0 +1,6 @@ +{ + "eagerEsModules": true, + "language": "typescript", + "schema": "./schema.graphql", + "src": "./src" +} diff --git a/tools/webapp-graphql-relay-lifecycle-fixture/schema.graphql b/tools/webapp-graphql-relay-lifecycle-fixture/schema.graphql new file mode 100644 index 0000000..5c4ac32 --- /dev/null +++ b/tools/webapp-graphql-relay-lifecycle-fixture/schema.graphql @@ -0,0 +1,9 @@ +type Query { + item(id: ID!): Item! + secondaryItem(id: ID!): Item! +} + +type Item { + id: ID! + label: String! +} diff --git a/tools/webapp-graphql-relay-lifecycle-fixture/src/LifecycleItem.ts b/tools/webapp-graphql-relay-lifecycle-fixture/src/LifecycleItem.ts new file mode 100644 index 0000000..46fce2d --- /dev/null +++ b/tools/webapp-graphql-relay-lifecycle-fixture/src/LifecycleItem.ts @@ -0,0 +1,10 @@ +import { graphql } from "react-relay"; + +export const lifecycleItemQuery = graphql` + query LifecycleItemQuery($id: ID!) { + item(id: $id) { + id + label + } + } +`; diff --git a/tools/webapp-graphql-relay-lifecycle-fixture/src/LifecyclePrimary.ts b/tools/webapp-graphql-relay-lifecycle-fixture/src/LifecyclePrimary.ts new file mode 100644 index 0000000..b344366 --- /dev/null +++ b/tools/webapp-graphql-relay-lifecycle-fixture/src/LifecyclePrimary.ts @@ -0,0 +1,10 @@ +import { graphql } from "react-relay"; + +export const lifecyclePrimaryQuery = graphql` + query LifecyclePrimaryQuery($id: ID!) { + item(id: $id) { + id + label + } + } +`; diff --git a/tools/webapp-graphql-relay-lifecycle-fixture/src/LifecycleSecondary.ts b/tools/webapp-graphql-relay-lifecycle-fixture/src/LifecycleSecondary.ts new file mode 100644 index 0000000..da2b71c --- /dev/null +++ b/tools/webapp-graphql-relay-lifecycle-fixture/src/LifecycleSecondary.ts @@ -0,0 +1,10 @@ +import { graphql } from "react-relay"; + +export const lifecycleSecondaryQuery = graphql` + query LifecycleSecondaryQuery($id: ID!) { + secondaryItem(id: $id) { + id + label + } + } +`; diff --git a/tools/webapp-graphql-relay-lifecycle-fixture/src/__generated__/LifecycleItemQuery.graphql.ts b/tools/webapp-graphql-relay-lifecycle-fixture/src/__generated__/LifecycleItemQuery.graphql.ts new file mode 100644 index 0000000..aa52d09 --- /dev/null +++ b/tools/webapp-graphql-relay-lifecycle-fixture/src/__generated__/LifecycleItemQuery.graphql.ts @@ -0,0 +1,97 @@ +/** + * @generated SignedSource<<27d1eac09f28e2530ec4098ae5d60f7e>> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest } from 'relay-runtime'; +export type LifecycleItemQuery$variables = { + id: string; +}; +export type LifecycleItemQuery$data = { + readonly item: { + readonly id: string; + readonly label: string; + }; +}; +export type LifecycleItemQuery = { + response: LifecycleItemQuery$data; + variables: LifecycleItemQuery$variables; +}; + +const node: ConcreteRequest = (function(){ +var v0 = [ + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "id" + } +], +v1 = [ + { + "alias": null, + "args": [ + { + "kind": "Variable", + "name": "id", + "variableName": "id" + } + ], + "concreteType": "Item", + "kind": "LinkedField", + "name": "item", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "label", + "storageKey": null + } + ], + "storageKey": null + } +]; +return { + "fragment": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Fragment", + "metadata": null, + "name": "LifecycleItemQuery", + "selections": (v1/*: any*/), + "type": "Query", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Operation", + "name": "LifecycleItemQuery", + "selections": (v1/*: any*/) + }, + "params": { + "cacheID": "bda7ccc6d5bca03f978e225a51b56338", + "id": null, + "metadata": {}, + "name": "LifecycleItemQuery", + "operationKind": "query", + "text": "query LifecycleItemQuery(\n $id: ID!\n) {\n item(id: $id) {\n id\n label\n }\n}\n" + } +}; +})(); + +(node as any).hash = "b13a1f127d645c1f0ea2a4602a40178c"; + +export default node; diff --git a/tools/webapp-graphql-relay-lifecycle-fixture/src/__generated__/LifecyclePrimaryQuery.graphql.ts b/tools/webapp-graphql-relay-lifecycle-fixture/src/__generated__/LifecyclePrimaryQuery.graphql.ts new file mode 100644 index 0000000..7b66fcf --- /dev/null +++ b/tools/webapp-graphql-relay-lifecycle-fixture/src/__generated__/LifecyclePrimaryQuery.graphql.ts @@ -0,0 +1,97 @@ +/** + * @generated SignedSource<<7a08d9bc5d69bfdd14c67be549b8f74c>> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest } from 'relay-runtime'; +export type LifecyclePrimaryQuery$variables = { + id: string; +}; +export type LifecyclePrimaryQuery$data = { + readonly item: { + readonly id: string; + readonly label: string; + }; +}; +export type LifecyclePrimaryQuery = { + response: LifecyclePrimaryQuery$data; + variables: LifecyclePrimaryQuery$variables; +}; + +const node: ConcreteRequest = (function(){ +var v0 = [ + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "id" + } +], +v1 = [ + { + "alias": null, + "args": [ + { + "kind": "Variable", + "name": "id", + "variableName": "id" + } + ], + "concreteType": "Item", + "kind": "LinkedField", + "name": "item", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "label", + "storageKey": null + } + ], + "storageKey": null + } +]; +return { + "fragment": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Fragment", + "metadata": null, + "name": "LifecyclePrimaryQuery", + "selections": (v1/*: any*/), + "type": "Query", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Operation", + "name": "LifecyclePrimaryQuery", + "selections": (v1/*: any*/) + }, + "params": { + "cacheID": "017e84f6fd195fb65295eb24258ab2ee", + "id": null, + "metadata": {}, + "name": "LifecyclePrimaryQuery", + "operationKind": "query", + "text": "query LifecyclePrimaryQuery(\n $id: ID!\n) {\n item(id: $id) {\n id\n label\n }\n}\n" + } +}; +})(); + +(node as any).hash = "e62bed6d2ae4c0c0055ff6e1d0b9717b"; + +export default node; diff --git a/tools/webapp-graphql-relay-lifecycle-fixture/src/__generated__/LifecycleSecondaryQuery.graphql.ts b/tools/webapp-graphql-relay-lifecycle-fixture/src/__generated__/LifecycleSecondaryQuery.graphql.ts new file mode 100644 index 0000000..8d94a8e --- /dev/null +++ b/tools/webapp-graphql-relay-lifecycle-fixture/src/__generated__/LifecycleSecondaryQuery.graphql.ts @@ -0,0 +1,97 @@ +/** + * @generated SignedSource<<8b6b712bd23dfaa4ea3d3811852c0cbd>> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest } from 'relay-runtime'; +export type LifecycleSecondaryQuery$variables = { + id: string; +}; +export type LifecycleSecondaryQuery$data = { + readonly secondaryItem: { + readonly id: string; + readonly label: string; + }; +}; +export type LifecycleSecondaryQuery = { + response: LifecycleSecondaryQuery$data; + variables: LifecycleSecondaryQuery$variables; +}; + +const node: ConcreteRequest = (function(){ +var v0 = [ + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "id" + } +], +v1 = [ + { + "alias": null, + "args": [ + { + "kind": "Variable", + "name": "id", + "variableName": "id" + } + ], + "concreteType": "Item", + "kind": "LinkedField", + "name": "secondaryItem", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "label", + "storageKey": null + } + ], + "storageKey": null + } +]; +return { + "fragment": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Fragment", + "metadata": null, + "name": "LifecycleSecondaryQuery", + "selections": (v1/*: any*/), + "type": "Query", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Operation", + "name": "LifecycleSecondaryQuery", + "selections": (v1/*: any*/) + }, + "params": { + "cacheID": "a04348dfd409403c79169a9b7047fd94", + "id": null, + "metadata": {}, + "name": "LifecycleSecondaryQuery", + "operationKind": "query", + "text": "query LifecycleSecondaryQuery(\n $id: ID!\n) {\n secondaryItem(id: $id) {\n id\n label\n }\n}\n" + } +}; +})(); + +(node as any).hash = "6658ac0438da5285f232cd3790f8e1c1"; + +export default node; diff --git a/tools/webapp-graphql-relay-lifecycle-fixture/src/browser.tsx b/tools/webapp-graphql-relay-lifecycle-fixture/src/browser.tsx new file mode 100644 index 0000000..9139bac --- /dev/null +++ b/tools/webapp-graphql-relay-lifecycle-fixture/src/browser.tsx @@ -0,0 +1,117 @@ +import { RouterProvider, createBrowserHistory } from "@tanstack/react-router"; +import { createRoot } from "react-dom/client"; +import { RelayEnvironmentProvider } from "react-relay"; +import { LifecycleLedger, type LifecycleEvent } from "./ledger.js"; +import { + ControlledRelayNetwork, + createControlledRelayEnvironment, +} from "./network.js"; +import { createLifecycleRouter } from "./router-fixture.js"; + +export type BrowserLifecycleFixture = { + beginNavigation(to: string): void; + events(): readonly LifecycleEvent[]; + finishNavigation(): Promise; + historyBack(): void; + historyForward(): void; + readonly runtimeId: string; + resolve(operationName: string, itemId: string, label: string): boolean; + teardown(): Promise; +}; + +declare global { + interface Window { + lifecycleFixture: BrowserLifecycleFixture; + } +} + +const container = document.querySelector("#root"); +if (!(container instanceof HTMLElement)) { + throw new Error("Lifecycle fixture root is missing."); +} + +const ledger = new LifecycleLedger(); +const runtimeId = crypto.randomUUID(); +ledger.record("test-run-start", { runtimeId }); +const network = new ControlledRelayNetwork(ledger); +const environment = createControlledRelayEnvironment(network); +const history = createBrowserHistory(); +const router = createLifecycleRouter(history, { environment, ledger }); +const root = createRoot(container, { + onCaughtError: (error) => { + ledger.record("react-caught-error", { + message: error instanceof Error ? error.message : String(error), + }); + }, + onRecoverableError: (error) => { + ledger.record("react-recoverable-error", { + message: error instanceof Error ? error.message : String(error), + }); + }, + onUncaughtError: (error) => { + throw error; + }, +}); +let pendingNavigation: Promise | undefined; +let tornDown = false; + +await router.load(); +root.render( + + + , +); + +window.lifecycleFixture = { + beginNavigation(to) { + if (pendingNavigation) { + throw new Error("A browser fixture navigation is already pending."); + } + ledger.record("navigation-start", { to }); + pendingNavigation = router.navigate({ to }); + }, + events: () => ledger.events(), + async finishNavigation() { + const navigation = pendingNavigation; + if (!navigation) { + throw new Error("No browser fixture navigation is pending."); + } + try { + await navigation; + ledger.record("navigation-complete", { + to: router.state.location.pathname, + }); + } finally { + pendingNavigation = undefined; + } + }, + historyBack() { + ledger.record("history-back"); + history.back(); + }, + historyForward() { + ledger.record("history-forward"); + history.forward(); + }, + resolve(operationName, itemId, label) { + const request = network.pendingRequest(operationName, itemId); + if (!request) { + return false; + } + network.resolve(request, label); + return true; + }, + runtimeId, + async teardown() { + if (tornDown) { + return ledger.events(); + } + tornDown = true; + if (router.state.location.pathname !== "/") { + await router.navigate({ to: "/" }); + } + router.clearCache(); + root.unmount(); + return ledger.events(); + }, +}; diff --git a/tools/webapp-graphql-relay-lifecycle-fixture/src/ledger.ts b/tools/webapp-graphql-relay-lifecycle-fixture/src/ledger.ts new file mode 100644 index 0000000..d29b68a --- /dev/null +++ b/tools/webapp-graphql-relay-lifecycle-fixture/src/ledger.ts @@ -0,0 +1,55 @@ +export type LifecycleEventDetails = Record< + string, + boolean | number | string | undefined +>; + +export type LifecycleEvent = { + details: LifecycleEventDetails; + kind: string; + sequence: number; +}; + +export class LifecycleLedger { + readonly #events: LifecycleEvent[] = []; + + public record(kind: string, details: LifecycleEventDetails = {}): void { + this.#events.push({ + details, + kind, + sequence: this.#events.length + 1, + }); + } + + public count( + kind: string, + predicate: (event: LifecycleEvent) => boolean = () => true, + ): number { + return this.#events.filter( + (event) => event.kind === kind && predicate(event), + ).length; + } + + public events(kind?: string): readonly LifecycleEvent[] { + return kind + ? this.#events.filter((event) => event.kind === kind) + : [...this.#events]; + } + + public first( + kind: string, + predicate: (event: LifecycleEvent) => boolean = () => true, + ): LifecycleEvent | undefined { + return this.#events.find( + (event) => event.kind === kind && predicate(event), + ); + } + + public format(): string { + return this.#events + .map( + (event) => + `${event.sequence}. ${event.kind} ${JSON.stringify(event.details)}`, + ) + .join("\n"); + } +} diff --git a/tools/webapp-graphql-relay-lifecycle-fixture/src/network.ts b/tools/webapp-graphql-relay-lifecycle-fixture/src/network.ts new file mode 100644 index 0000000..bd6ebe4 --- /dev/null +++ b/tools/webapp-graphql-relay-lifecycle-fixture/src/network.ts @@ -0,0 +1,180 @@ +import { + Environment, + Network, + Observable, + RecordSource, + Store, + type FetchFunction, + type GraphQLResponse, + type RequestParameters, + type Variables, +} from "relay-runtime"; +import { LifecycleLedger } from "./ledger.js"; + +type NetworkSink = Parameters[0]>[0]; + +export type ControlledRequest = { + itemId: string; + operationName: string; + requestId: string; +}; + +type PendingRequest = ControlledRequest & { + settled: boolean; + sink: NetworkSink; +}; + +type RequestWaiter = { + itemId: string; + operationName: string; + resolve(request: ControlledRequest): void; +}; + +export class ControlledRelayNetwork { + readonly #ledger: LifecycleLedger; + readonly #pending = new Map(); + readonly #requests: ControlledRequest[] = []; + readonly #waiters: RequestWaiter[] = []; + #nextRequest = 1; + + public constructor(ledger: LifecycleLedger) { + this.#ledger = ledger; + } + + public readonly fetch: FetchFunction = ( + request: RequestParameters, + variables: Variables, + ) => + Observable.create((sink) => { + const operationName = request.name; + const itemId = String(variables.id); + const requestId = `request-${this.#nextRequest}`; + this.#nextRequest += 1; + const controlledRequest = { itemId, operationName, requestId }; + const pendingRequest: PendingRequest = { + ...controlledRequest, + settled: false, + sink, + }; + this.#pending.set(requestId, pendingRequest); + this.#requests.push(controlledRequest); + this.#ledger.record("network-subscribe", controlledRequest); + this.#resolveWaiters(controlledRequest); + + return () => { + if (!pendingRequest.settled) { + this.#ledger.record("network-cancel", controlledRequest); + } + this.#ledger.record("network-unsubscribe", controlledRequest); + this.#pending.delete(requestId); + }; + }); + + public requests( + operationName?: string, + itemId?: string, + ): readonly ControlledRequest[] { + return this.#requests.filter( + (request) => + (!operationName || request.operationName === operationName) && + (!itemId || request.itemId === itemId), + ); + } + + public waitForRequest( + operationName: string, + itemId: string, + occurrence = 0, + ): Promise { + const existing = this.requests(operationName, itemId)[occurrence]; + if (existing) { + return Promise.resolve(existing); + } + + return new Promise((resolve) => { + this.#waiters.push({ itemId, operationName, resolve }); + }); + } + + public resolve(request: ControlledRequest, label?: string): void { + const pending = this.#getPending(request); + pending.settled = true; + const field = + request.operationName === "LifecycleSecondaryQuery" + ? "secondaryItem" + : "item"; + const recordId = + request.operationName === "LifecycleSecondaryQuery" + ? `${request.itemId}-secondary` + : request.itemId; + const response = { + data: { + [field]: { + id: recordId, + label: label ?? `${request.operationName}:${request.itemId}`, + }, + }, + } as GraphQLResponse; + this.#ledger.record("network-next", request); + pending.sink.next(response); + this.#ledger.record("network-complete", request); + pending.sink.complete(); + } + + public reject(request: ControlledRequest, message: string): void { + const pending = this.#getPending(request); + pending.settled = true; + this.#ledger.record("network-error", { + ...request, + message, + }); + pending.sink.error(new Error(message)); + } + + public pendingCount(): number { + return [...this.#pending.values()].filter((request) => !request.settled) + .length; + } + + public pendingRequest( + operationName: string, + itemId: string, + ): ControlledRequest | undefined { + return [...this.#pending.values()].find( + (request) => + !request.settled && + request.operationName === operationName && + request.itemId === itemId, + ); + } + + #getPending(request: ControlledRequest): PendingRequest { + const pending = this.#pending.get(request.requestId); + if (!pending || pending.settled) { + throw new Error(`Request ${request.requestId} is not pending.`); + } + return pending; + } + + #resolveWaiters(request: ControlledRequest): void { + const matchingIndex = this.#waiters.findIndex( + (waiter) => + waiter.operationName === request.operationName && + waiter.itemId === request.itemId, + ); + if (matchingIndex < 0) { + return; + } + const [waiter] = this.#waiters.splice(matchingIndex, 1); + waiter.resolve(request); + } +} + +export function createControlledRelayEnvironment( + network: ControlledRelayNetwork, +): Environment { + return new Environment({ + network: Network.create(network.fetch), + store: new Store(new RecordSource()), + }); +} diff --git a/tools/webapp-graphql-relay-lifecycle-fixture/src/router-fixture.tsx b/tools/webapp-graphql-relay-lifecycle-fixture/src/router-fixture.tsx new file mode 100644 index 0000000..b420c34 --- /dev/null +++ b/tools/webapp-graphql-relay-lifecycle-fixture/src/router-fixture.tsx @@ -0,0 +1,349 @@ +import { + createRouteQueryLifetime, + loadRouteQuery, + useRouteQueryLifetime, + type RouteAbortSignal, + type RouteQueryLifetime, +} from "@omgjs/labkit-webapp-graphql-relay"; +import { + Outlet, + createRootRouteWithContext, + createRoute, + createRouter, + useRouter, + type RouterHistory, +} from "@tanstack/react-router"; +import { useEffect, type ReactElement } from "react"; +import { usePreloadedQuery, type PreloadedQuery } from "react-relay"; +import type { Environment } from "relay-runtime"; +import lifecycleItemQuery, { + type LifecycleItemQuery, +} from "./__generated__/LifecycleItemQuery.graphql.js"; +import lifecyclePrimaryQuery, { + type LifecyclePrimaryQuery, +} from "./__generated__/LifecyclePrimaryQuery.graphql.js"; +import lifecycleSecondaryQuery, { + type LifecycleSecondaryQuery, +} from "./__generated__/LifecycleSecondaryQuery.graphql.js"; +import { LifecycleLedger } from "./ledger.js"; + +type FixtureContext = { + environment: Environment; + ledger: LifecycleLedger; +}; + +type SingleLoaderData = { + itemId: string; + ledger: LifecycleLedger; + queryLifetime: RouteQueryLifetime; + queryReference: PreloadedQuery; + referenceId: string; +}; + +type PairLoaderData = { + itemId: string; + ledger: LifecycleLedger; + primaryQueryReference: PreloadedQuery; + primaryReferenceId: string; + secondaryQueryReference: PreloadedQuery; + secondaryReferenceId: string; + queryLifetime: RouteQueryLifetime; +}; + +export type LifecycleRouterOptions = { + defaultGcTime?: number; + defaultPreloadGcTime?: number; + defaultPreloadStaleTime?: number; + defaultStaleReloadMode?: "background" | "blocking"; + defaultStaleTime?: number; + failPairAfterPrimary?: boolean; +}; + +function recordReferenceRelease( + signal: RouteAbortSignal, + ledger: LifecycleLedger, + referenceId: string, + operationName: string, + itemId: string, +): void { + signal.addEventListener( + "abort", + () => { + ledger.record("reference-release", { + itemId, + operationName, + referenceId, + }); + }, + { once: true }, + ); +} + +export function createLifecycleRouter( + history: RouterHistory, + context: FixtureContext, + options: LifecycleRouterOptions = {}, +) { + let nextReference = 1; + const createReferenceId = (): string => { + const referenceId = `reference-${nextReference}`; + nextReference += 1; + return referenceId; + }; + const rootRoute = createRootRouteWithContext()({ + component: RootComponent, + pendingComponent: PendingComponent, + }); + const landingRoute = createRoute({ + component: LandingComponent, + getParentRoute: () => rootRoute, + path: "/", + }); + const itemRoute = createRoute({ + component: ItemComponent, + errorComponent: RouteErrorComponent, + getParentRoute: () => rootRoute, + loader: ({ abortController, context: routeContext, params, preload }) => { + const itemId = params.itemId; + const queryLifetime = createRouteQueryLifetime({ + routeAbortSignal: abortController.signal, + }); + routeContext.ledger.record("loader-start", { + itemId, + operationName: "LifecycleItemQuery", + preload, + }); + const queryReference = loadRouteQuery({ + environment: routeContext.environment, + fetchPolicy: "store-and-network", + lifetime: queryLifetime, + query: lifecycleItemQuery, + variables: { id: itemId }, + }); + const referenceId = createReferenceId(); + routeContext.ledger.record("reference-create", { + itemId, + operationName: "LifecycleItemQuery", + preload, + referenceId, + }); + recordReferenceRelease( + queryLifetime.abortSignal, + routeContext.ledger, + referenceId, + "LifecycleItemQuery", + itemId, + ); + return { + itemId, + ledger: routeContext.ledger, + queryLifetime, + queryReference, + referenceId, + } satisfies SingleLoaderData; + }, + pendingComponent: PendingComponent, + path: "items/$itemId", + }); + const pairRoute = createRoute({ + component: PairComponent, + errorComponent: RouteErrorComponent, + getParentRoute: () => rootRoute, + loader: ({ abortController, context: routeContext, params, preload }) => { + const itemId = params.itemId; + const queryLifetime = createRouteQueryLifetime({ + routeAbortSignal: abortController.signal, + }); + routeContext.ledger.record("loader-start", { + itemId, + operationName: "LifecyclePrimaryQuery", + preload, + }); + const primaryQueryReference = loadRouteQuery({ + environment: routeContext.environment, + fetchPolicy: "store-and-network", + lifetime: queryLifetime, + query: lifecyclePrimaryQuery, + variables: { id: itemId }, + }); + const primaryReferenceId = createReferenceId(); + routeContext.ledger.record("reference-create", { + itemId, + operationName: "LifecyclePrimaryQuery", + preload, + referenceId: primaryReferenceId, + }); + recordReferenceRelease( + queryLifetime.abortSignal, + routeContext.ledger, + primaryReferenceId, + "LifecyclePrimaryQuery", + itemId, + ); + if (options.failPairAfterPrimary) { + const error = new Error("Expected partial pair construction failure"); + queryLifetime.abort(error); + throw error; + } + + routeContext.ledger.record("loader-start", { + itemId, + operationName: "LifecycleSecondaryQuery", + preload, + }); + let secondaryQueryReference: PreloadedQuery; + try { + secondaryQueryReference = loadRouteQuery({ + environment: routeContext.environment, + fetchPolicy: "store-and-network", + lifetime: queryLifetime, + query: lifecycleSecondaryQuery, + variables: { id: itemId }, + }); + } catch (error) { + queryLifetime.abort(error); + throw error; + } + const secondaryReferenceId = createReferenceId(); + routeContext.ledger.record("reference-create", { + itemId, + operationName: "LifecycleSecondaryQuery", + preload, + referenceId: secondaryReferenceId, + }); + recordReferenceRelease( + queryLifetime.abortSignal, + routeContext.ledger, + secondaryReferenceId, + "LifecycleSecondaryQuery", + itemId, + ); + return { + itemId, + ledger: routeContext.ledger, + primaryQueryReference, + primaryReferenceId, + queryLifetime, + secondaryQueryReference, + secondaryReferenceId, + } satisfies PairLoaderData; + }, + pendingComponent: PendingComponent, + path: "pairs/$itemId", + }); + const routeTree = rootRoute.addChildren([landingRoute, itemRoute, pairRoute]); + + return createRouter({ + context, + defaultGcTime: options.defaultGcTime ?? 0, + defaultPendingMinMs: 0, + defaultPendingMs: 0, + defaultPreloadGcTime: options.defaultPreloadGcTime ?? 300_000, + defaultPreloadStaleTime: options.defaultPreloadStaleTime ?? 30_000, + defaultStaleReloadMode: options.defaultStaleReloadMode ?? "blocking", + defaultStaleTime: options.defaultStaleTime ?? 0, + history, + routeTree, + }); + + function RootComponent(): ReactElement { + return ; + } + + function LandingComponent(): ReactElement { + return
Landing
; + } + + function PendingComponent(): ReactElement { + return
Loading
; + } + + function ItemComponent(): ReactElement { + const loaderData = itemRoute.useLoaderData() as unknown as SingleLoaderData; + useRouteQueryLifetime(loaderData.queryLifetime); + const data = usePreloadedQuery( + lifecycleItemQuery, + loaderData.queryReference, + ); + loaderData.ledger.record("render", { + itemId: loaderData.itemId, + label: data.item.label, + referenceId: loaderData.referenceId, + }); + useEffect(() => { + loaderData.ledger.record("mount", { + itemId: loaderData.itemId, + referenceId: loaderData.referenceId, + }); + return () => { + loaderData.ledger.record("unmount", { + itemId: loaderData.itemId, + referenceId: loaderData.referenceId, + }); + }; + }, [loaderData]); + return ( +
+ {data.item.label} +
+ ); + } + + function PairComponent(): ReactElement { + const loaderData = pairRoute.useLoaderData() as unknown as PairLoaderData; + useRouteQueryLifetime(loaderData.queryLifetime); + const primaryData = usePreloadedQuery( + lifecyclePrimaryQuery, + loaderData.primaryQueryReference, + ); + const secondaryData = usePreloadedQuery( + lifecycleSecondaryQuery, + loaderData.secondaryQueryReference, + ); + loaderData.ledger.record("render-pair", { + itemId: loaderData.itemId, + primaryReferenceId: loaderData.primaryReferenceId, + secondaryReferenceId: loaderData.secondaryReferenceId, + }); + useEffect(() => { + loaderData.ledger.record("mount-pair", { + itemId: loaderData.itemId, + primaryReferenceId: loaderData.primaryReferenceId, + secondaryReferenceId: loaderData.secondaryReferenceId, + }); + return () => { + loaderData.ledger.record("unmount-pair", { + itemId: loaderData.itemId, + primaryReferenceId: loaderData.primaryReferenceId, + secondaryReferenceId: loaderData.secondaryReferenceId, + }); + }; + }, [loaderData]); + return ( +
+ {primaryData.item.label} + {secondaryData.secondaryItem.label} +
+ ); + } + + function RouteErrorComponent({ error }: { error: unknown }): ReactElement { + const router = useRouter(); + const message = error instanceof Error ? error.message : String(error); + return ( +
+ {message} + +
+ ); + } +} diff --git a/tools/webapp-graphql-relay-lifecycle-fixture/src/runtime-contract.ts b/tools/webapp-graphql-relay-lifecycle-fixture/src/runtime-contract.ts new file mode 100644 index 0000000..bdd2377 --- /dev/null +++ b/tools/webapp-graphql-relay-lifecycle-fixture/src/runtime-contract.ts @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import { realpathSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; + +const fixtureRequire = createRequire(import.meta.url); + +type PackageContract = { + name: string; + version: string; +}; + +function packageManifestFromEntry(entry: string): PackageContract { + let directory = dirname(realpathSync(entry)); + while (true) { + const manifestPath = join(directory, "package.json"); + try { + const manifest = fixtureRequire(manifestPath) as Partial; + if (manifest.name && manifest.version) { + return { name: manifest.name, version: manifest.version }; + } + } catch { + // Continue toward the filesystem root until the owning manifest is found. + } + const parent = dirname(directory); + if (parent === directory) { + throw new Error(`Could not find a package manifest for ${entry}.`); + } + directory = parent; + } +} + +function resolveFrom(entry: string, dependency: string): string { + return realpathSync(createRequire(entry).resolve(dependency)); +} + +export type RuntimeContractEvidence = { + labkitEntry: string; + reactRelayEntry: string; + reactRelayVersion: string; + relayRuntimeEntry: string; + relayRuntimeVersion: string; +}; + +export type RelayRuntimeIdentity = { + fixtureReactRelay: string; + fixtureRelayRuntime: string; + labkitReactRelay: string; + labkitRelayRuntime: string; + reactRelayRuntime: string; + reactRelayVersion: string; + relayRuntimeVersion: string; +}; + +export function assertRelayRuntimeIdentity( + identity: RelayRuntimeIdentity, +): void { + assert.equal(identity.reactRelayVersion, "20.1.1"); + assert.equal(identity.relayRuntimeVersion, "20.1.1"); + assert.equal(identity.labkitReactRelay, identity.fixtureReactRelay); + assert.equal(identity.labkitRelayRuntime, identity.fixtureRelayRuntime); + assert.equal(identity.reactRelayRuntime, identity.fixtureRelayRuntime); +} + +export function assertSingleRelayRuntime(): RuntimeContractEvidence { + const fixtureReactRelay = realpathSync(fixtureRequire.resolve("react-relay")); + const fixtureRelayRuntime = realpathSync( + fixtureRequire.resolve("relay-runtime"), + ); + const labkitEntry = realpathSync( + fixtureRequire.resolve("@omgjs/labkit-webapp-graphql-relay"), + ); + const labkitReactRelay = resolveFrom(labkitEntry, "react-relay"); + const labkitRelayRuntime = resolveFrom(labkitEntry, "relay-runtime"); + const reactRelayRuntime = resolveFrom(fixtureReactRelay, "relay-runtime"); + const reactRelayManifest = packageManifestFromEntry(fixtureReactRelay); + const relayRuntimeManifest = packageManifestFromEntry(fixtureRelayRuntime); + + assertRelayRuntimeIdentity({ + fixtureReactRelay, + fixtureRelayRuntime, + labkitReactRelay, + labkitRelayRuntime, + reactRelayRuntime, + reactRelayVersion: reactRelayManifest.version, + relayRuntimeVersion: relayRuntimeManifest.version, + }); + + return { + labkitEntry, + reactRelayEntry: fixtureReactRelay, + reactRelayVersion: reactRelayManifest.version, + relayRuntimeEntry: fixtureRelayRuntime, + relayRuntimeVersion: relayRuntimeManifest.version, + }; +} diff --git a/tools/webapp-graphql-relay-lifecycle-fixture/test/console-guard.ts b/tools/webapp-graphql-relay-lifecycle-fixture/test/console-guard.ts new file mode 100644 index 0000000..7496b06 --- /dev/null +++ b/tools/webapp-graphql-relay-lifecycle-fixture/test/console-guard.ts @@ -0,0 +1,55 @@ +import { afterEach, beforeEach, vi } from "vitest"; + +let messages: string[] = []; +let expectedMessages: RegExp[] = []; +let restoreError: (() => void) | undefined; +let restoreWarn: (() => void) | undefined; + +beforeEach(() => { + messages = []; + expectedMessages = []; + const errorSpy = vi.spyOn(console, "error").mockImplementation((...args) => { + messages.push(`console.error: ${args.map(String).join(" ")}`); + }); + const warnSpy = vi.spyOn(console, "warn").mockImplementation((...args) => { + messages.push(`console.warn: ${args.map(String).join(" ")}`); + }); + restoreError = () => errorSpy.mockRestore(); + restoreWarn = () => warnSpy.mockRestore(); +}); + +afterEach(() => { + restoreError?.(); + restoreWarn?.(); + restoreError = undefined; + restoreWarn = undefined; + const unmatchedExpected = [...expectedMessages]; + const unexpected = messages.filter((message) => { + const matchIndex = unmatchedExpected.findIndex((pattern) => + pattern.test(message), + ); + if (matchIndex < 0) { + return true; + } + unmatchedExpected.splice(matchIndex, 1); + return false; + }); + if (unexpected.length > 0 || unmatchedExpected.length > 0) { + throw new Error( + [ + unexpected.length > 0 + ? `Unexpected console output:\n${unexpected.join("\n")}` + : undefined, + unmatchedExpected.length > 0 + ? `Expected console output was not observed:\n${unmatchedExpected.join("\n")}` + : undefined, + ] + .filter(Boolean) + .join("\n"), + ); + } +}); + +export function expectConsoleOutput(pattern: RegExp): void { + expectedMessages.push(pattern); +} diff --git a/tools/webapp-graphql-relay-lifecycle-fixture/test/contract.test.ts b/tools/webapp-graphql-relay-lifecycle-fixture/test/contract.test.ts new file mode 100644 index 0000000..0257230 --- /dev/null +++ b/tools/webapp-graphql-relay-lifecycle-fixture/test/contract.test.ts @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { loadRouteQuery } from "@omgjs/labkit-webapp-graphql-relay"; +import { test } from "vitest"; +import type { PreloadedQuery } from "react-relay"; +import type { Environment, OperationType } from "relay-runtime"; +import { assertRelayRuntimeIdentity } from "../src/runtime-contract.js"; + +type ContractQuery = OperationType & { + response: { readonly item: { readonly id: string } }; + variables: { id: string }; +}; + +function loadTrackedReference( + signal: AbortSignal, + dispose: () => void, +): PreloadedQuery { + // Keep permanent coverage for the deprecated abort-only compatibility arm. + return loadRouteQuery({ + abortSignal: signal, + environment: {} as Environment, + loadQuery: () => ({ dispose }) as PreloadedQuery, + query: {} as never, + variables: { id: "contract" }, + }); +} + +test("B3: an already-aborted signal disposes exactly once", () => { + const controller = new AbortController(); + controller.abort(); + let disposals = 0; + loadTrackedReference(controller.signal, () => { + disposals += 1; + }); + controller.abort(); + assert.equal(disposals, 1); +}); + +test("repeated abort notifications dispose each independently owned reference once", () => { + const controller = new AbortController(); + const disposals = [0, 0]; + loadTrackedReference(controller.signal, () => { + disposals[0] += 1; + }); + loadTrackedReference(controller.signal, () => { + disposals[1] += 1; + }); + controller.abort(); + controller.abort(); + assert.deepEqual(disposals, [1, 1]); +}); + +test("the runtime preflight rejects a duplicate Relay path", () => { + assert.throws(() => + assertRelayRuntimeIdentity({ + fixtureReactRelay: "/fixture/react-relay", + fixtureRelayRuntime: "/fixture/relay-runtime", + labkitReactRelay: "/fixture/react-relay", + labkitRelayRuntime: "/nested/relay-runtime", + reactRelayRuntime: "/fixture/relay-runtime", + reactRelayVersion: "20.1.1", + relayRuntimeVersion: "20.1.1", + }), + ); +}); diff --git a/tools/webapp-graphql-relay-lifecycle-fixture/test/harness.tsx b/tools/webapp-graphql-relay-lifecycle-fixture/test/harness.tsx new file mode 100644 index 0000000..a40e062 --- /dev/null +++ b/tools/webapp-graphql-relay-lifecycle-fixture/test/harness.tsx @@ -0,0 +1,221 @@ +import { act, StrictMode, type ReactElement } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { RelayEnvironmentProvider } from "react-relay"; +import { RouterProvider, createMemoryHistory } from "@tanstack/react-router"; +import { LifecycleLedger } from "../src/ledger.js"; +import { + ControlledRelayNetwork, + createControlledRelayEnvironment, +} from "../src/network.js"; +import { + createLifecycleRouter, + type LifecycleRouterOptions, +} from "../src/router-fixture.js"; + +type ReactActGlobal = typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}; + +(globalThis as ReactActGlobal).IS_REACT_ACT_ENVIRONMENT = true; + +export type LifecycleHarness = Awaited>; + +export async function createHarness(options: { + initialEntry: string; + routerOptions?: LifecycleRouterOptions; + strictMode?: boolean; +}) { + window.scrollTo = () => undefined; + Object.defineProperty(globalThis, "scrollTo", { + configurable: true, + value: window.scrollTo, + writable: true, + }); + const container = document.createElement("div"); + container.id = "root"; + document.body.replaceChildren(container); + const ledger = new LifecycleLedger(); + ledger.record("test-run-start", { runtimeId: "runtime-1" }); + const network = new ControlledRelayNetwork(ledger); + const environment = createControlledRelayEnvironment(network); + const history = createMemoryHistory({ + initialEntries: [options.initialEntry], + }); + const router = createLifecycleRouter( + history, + { environment, ledger }, + options.routerOptions, + ); + const root = createRoot(container, { + onCaughtError: (error) => { + ledger.record("react-caught-error", { + message: error instanceof Error ? error.message : String(error), + }); + }, + onRecoverableError: (error) => { + ledger.record("react-recoverable-error", { + message: error instanceof Error ? error.message : String(error), + }); + }, + onUncaughtError: (error) => { + throw error; + }, + }); + let tornDown = false; + await router.load(); + const application = ( + + + + ); + await act(async () => { + root.render( + options.strictMode ? {application} : application, + ); + }); + + const beginNavigation = async (to: string) => { + let navigation: Promise; + ledger.record("navigation-start", { to }); + await act(async () => { + navigation = router.navigate({ to }); + }); + const finished = navigation!.then( + () => { + ledger.record("navigation-complete", { to }); + }, + (error: unknown) => { + ledger.record("navigation-error", { + message: error instanceof Error ? error.message : String(error), + to, + }); + throw error; + }, + ); + return { finished }; + }; + + const navigate = async (to: string) => { + const navigation = await beginNavigation(to); + await navigation.finished; + }; + + const clearCache = () => { + ledger.record("cache-clear"); + router.clearCache(); + }; + + const historyBack = () => { + ledger.record("history-back"); + history.back(); + }; + + const historyForward = () => { + ledger.record("history-forward"); + history.forward(); + }; + + const invalidate = () => { + ledger.record("route-invalidate", { + to: router.state.location.pathname, + }); + return router.invalidate({ forcePending: true, sync: true }); + }; + + const preloadItem = (itemId: string) => { + ledger.record("preload-start", { itemId }); + return router.preloadRoute({ + params: { itemId }, + to: "/items/$itemId", + }); + }; + + return { + beginNavigation, + clearCache, + container, + environment, + history, + historyBack, + historyForward, + invalidate, + ledger, + network, + preloadItem, + root, + router, + async flush() { + await act(async () => { + await Promise.resolve(); + }); + }, + async teardown() { + if (tornDown) { + return; + } + tornDown = true; + if (router.state.location.pathname !== "/") { + await navigate("/"); + } + clearCache(); + await act(async () => { + root.unmount(); + }); + container.remove(); + }, + async waitForText(testId: string, expected: string) { + await waitForMutation( + container, + () => { + const target = container.querySelector(`[data-testid="${testId}"]`); + return target?.textContent?.includes(expected) === true; + }, + ledger, + ); + }, + navigate, + }; +} + +async function waitForMutation( + container: Element, + predicate: () => boolean, + ledger: LifecycleLedger, +): Promise { + if (predicate()) { + return; + } + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + observer.disconnect(); + reject( + new Error( + `DOM condition was not met. Current DOM: ${container.innerHTML}\n${ledger.format()}`, + ), + ); + }, 5_000); + const observer = new MutationObserver(() => { + if (!predicate()) { + return; + } + clearTimeout(timeout); + observer.disconnect(); + resolve(); + }); + observer.observe(container, { + childList: true, + characterData: true, + subtree: true, + }); + }); +} + +export function renderWithOptionalStrictMode( + root: Root, + application: ReactElement, + strictMode: boolean, +): void { + root.render( + strictMode ? {application} : application, + ); +} diff --git a/tools/webapp-graphql-relay-lifecycle-fixture/test/history.test.ts b/tools/webapp-graphql-relay-lifecycle-fixture/test/history.test.ts new file mode 100644 index 0000000..7abe972 --- /dev/null +++ b/tools/webapp-graphql-relay-lifecycle-fixture/test/history.test.ts @@ -0,0 +1,130 @@ +import assert from "node:assert/strict"; +import { act } from "react"; +import { test } from "vitest"; +import { createHarness } from "./harness.js"; + +async function resolveItem( + harness: Awaited>, + itemId: string, + occurrence: number, + label: string, +): Promise { + const request = await harness.network.waitForRequest( + "LifecycleItemQuery", + itemId, + occurrence, + ); + await act(async () => { + harness.network.resolve(request, label); + }); + await harness.waitForText("item", label); +} + +test("H2: browser forward follows the same reload-before-render policy", async () => { + const harness = await createHarness({ initialEntry: "/items/A" }); + try { + await resolveItem(harness, "A", 0, "A first"); + const { finished: navigationB } = await harness.beginNavigation("/items/B"); + await resolveItem(harness, "B", 0, "B first"); + await navigationB; + + await act(async () => { + harness.historyBack(); + }); + await resolveItem(harness, "A", 1, "A back"); + + await act(async () => { + harness.historyForward(); + }); + await resolveItem(harness, "B", 1, "B forward"); + + assert.equal( + harness.network.requests("LifecycleItemQuery", "A").length, + 2, + harness.ledger.format(), + ); + assert.equal( + harness.network.requests("LifecycleItemQuery", "B").length, + 2, + harness.ledger.format(), + ); + assert.match(harness.container.textContent ?? "", /B forward/); + } finally { + await harness.teardown(); + } +}); + +test("H3: explicit cache clearing cannot revive a released reference", async () => { + const harness = await createHarness({ initialEntry: "/items/A" }); + try { + await resolveItem(harness, "A", 0, "A before clear"); + await harness.navigate("/"); + await harness.waitForText("landing", "Landing"); + harness.clearCache(); + + const { finished: navigation } = await harness.beginNavigation("/items/A"); + await resolveItem(harness, "A", 1, "A after clear"); + await navigation; + assert.equal( + harness.ledger.count( + "reference-create", + (event) => event.details.itemId === "A", + ), + 2, + harness.ledger.format(), + ); + assert.equal( + harness.ledger.count( + "reference-release", + (event) => event.details.itemId === "A", + ), + 1, + harness.ledger.format(), + ); + } finally { + await harness.teardown(); + } +}); + +test("H4/R2: blocking invalidation keeps active data until replacement commits", async () => { + const harness = await createHarness({ initialEntry: "/items/A" }); + try { + await resolveItem(harness, "A", 0, "A original"); + let invalidation: Promise; + await act(async () => { + invalidation = harness.invalidate(); + }); + const replacement = await harness.network.waitForRequest( + "LifecycleItemQuery", + "A", + 1, + ); + assert.match(harness.container.textContent ?? "", /A original/); + assert.equal( + harness.ledger.count("reference-release"), + 1, + harness.ledger.format(), + ); + assert.equal( + harness.ledger.count( + "render", + (event) => event.details.referenceId === "reference-2", + ), + 1, + harness.ledger.format(), + ); + + await act(async () => { + harness.network.resolve(replacement, "A refreshed"); + }); + await invalidation!; + await harness.waitForText("item", "A refreshed"); + assert.equal( + harness.ledger.count("reference-release"), + 1, + harness.ledger.format(), + ); + } finally { + await harness.teardown(); + } +}); diff --git a/tools/webapp-graphql-relay-lifecycle-fixture/test/lifecycle.test.ts b/tools/webapp-graphql-relay-lifecycle-fixture/test/lifecycle.test.ts new file mode 100644 index 0000000..2e96a75 --- /dev/null +++ b/tools/webapp-graphql-relay-lifecycle-fixture/test/lifecycle.test.ts @@ -0,0 +1,295 @@ +import assert from "node:assert/strict"; +import { act } from "react"; +import { test } from "vitest"; +import { assertSingleRelayRuntime } from "../src/runtime-contract.js"; +import { expectConsoleOutput } from "./console-guard.js"; +import { createHarness } from "./harness.js"; + +test("one supported Relay runtime is shared by the fixture, Labkit, and React Relay", () => { + const evidence = assertSingleRelayRuntime(); + assert.equal(evidence.reactRelayVersion, "20.1.1"); + assert.equal(evidence.relayRuntimeVersion, "20.1.1"); +}); + +test("B1: direct initial entry suspends and renders the resolved query", async () => { + const harness = await createHarness({ initialEntry: "/items/A" }); + try { + const request = await harness.network.waitForRequest( + "LifecycleItemQuery", + "A", + ); + await act(async () => { + harness.network.resolve(request, "Item A"); + }); + await harness.waitForText("item", "Item A"); + assert.equal(harness.ledger.count("reference-create"), 1); + assert.equal(harness.ledger.count("reference-release"), 0); + } finally { + await harness.teardown(); + } +}); + +test("N1: rendered A remains visible until pending B can commit", async () => { + const harness = await createHarness({ initialEntry: "/items/A" }); + try { + const requestA = await harness.network.waitForRequest( + "LifecycleItemQuery", + "A", + ); + await act(async () => { + harness.network.resolve(requestA, "Item A"); + }); + await harness.waitForText("item", "Item A"); + + const { finished: navigationB } = await harness.beginNavigation("/items/B"); + const requestB = await harness.network.waitForRequest( + "LifecycleItemQuery", + "B", + ); + assert.match(harness.container.textContent ?? "", /Item A/); + assert.equal( + harness.ledger.count( + "reference-release", + (event) => event.details.itemId === "A", + ), + 0, + harness.ledger.format(), + ); + + await act(async () => { + harness.network.resolve(requestB, "Item B"); + }); + await navigationB; + await harness.waitForText("item", "Item B"); + assert.equal( + harness.ledger.count( + "reference-release", + (event) => event.details.itemId === "A", + ), + 1, + harness.ledger.format(), + ); + } finally { + await harness.teardown(); + } +}); + +test("N2: superseded pending work is canceled and cannot commit late", async () => { + const harness = await createHarness({ initialEntry: "/items/A" }); + try { + const requestA = await harness.network.waitForRequest( + "LifecycleItemQuery", + "A", + ); + const { finished: navigationB } = await harness.beginNavigation("/items/B"); + const requestB = await harness.network.waitForRequest( + "LifecycleItemQuery", + "B", + ); + await act(async () => { + harness.network.resolve(requestB, "Item B"); + }); + await navigationB; + await harness.flush(); + await harness.waitForText("item", "Item B"); + assert.equal( + harness.ledger.count( + "network-cancel", + (event) => event.details.requestId === requestA.requestId, + ), + 1, + harness.ledger.format(), + ); + assert.equal( + harness.ledger.count( + "reference-release", + (event) => event.details.itemId === "A", + ), + 1, + harness.ledger.format(), + ); + assert.throws(() => harness.network.resolve(requestA, "Late A")); + assert.match(harness.container.textContent ?? "", /Item B/); + } finally { + await harness.teardown(); + } +}); + +test("N3: rapid replacements commit only the final route", async () => { + const harness = await createHarness({ initialEntry: "/items/A1" }); + try { + const requestA1 = await harness.network.waitForRequest( + "LifecycleItemQuery", + "A1", + ); + const { finished: navigationA2 } = + await harness.beginNavigation("/items/A2"); + const requestA2 = await harness.network.waitForRequest( + "LifecycleItemQuery", + "A2", + ); + const { finished: navigationB } = await harness.beginNavigation("/items/B"); + const requestB = await harness.network.waitForRequest( + "LifecycleItemQuery", + "B", + ); + + await act(async () => { + harness.network.resolve(requestB, "Final B"); + }); + await Promise.all([navigationA2, navigationB]); + await harness.waitForText("item", "Final B"); + assert.throws(() => harness.network.resolve(requestA1, "Late A1")); + assert.throws(() => harness.network.resolve(requestA2, "Late A2")); + assert.equal( + harness.ledger.count("network-cancel"), + 2, + harness.ledger.format(), + ); + assert.equal( + harness.ledger.count("reference-release"), + 2, + harness.ledger.format(), + ); + assert.match(harness.container.textContent ?? "", /Final B/); + } finally { + await harness.teardown(); + } +}); + +test("N4: route parameter replacement keeps identities distinct", async () => { + const harness = await createHarness({ initialEntry: "/items/one" }); + try { + const first = await harness.network.waitForRequest( + "LifecycleItemQuery", + "one", + ); + await act(async () => { + harness.network.resolve(first, "Identity one"); + }); + await harness.waitForText("item", "Identity one"); + + const { finished: navigation } = + await harness.beginNavigation("/items/two"); + const second = await harness.network.waitForRequest( + "LifecycleItemQuery", + "two", + ); + assert.match(harness.container.textContent ?? "", /Identity one/); + await act(async () => { + harness.network.resolve(second, "Identity two"); + }); + await navigation; + await harness.waitForText("item", "Identity two"); + assert.equal( + harness.ledger.count( + "reference-release", + (event) => event.details.itemId === "one", + ), + 1, + harness.ledger.format(), + ); + assert.match(harness.container.textContent ?? "", /Identity two/); + assert.doesNotMatch(harness.container.textContent ?? "", /Identity one/); + } finally { + await harness.teardown(); + } +}); + +test("H1: back reloads before render instead of reusing a released reference", async () => { + const harness = await createHarness({ initialEntry: "/items/A" }); + try { + const firstA = await harness.network.waitForRequest( + "LifecycleItemQuery", + "A", + ); + await act(async () => { + harness.network.resolve(firstA, "Item A first"); + }); + await harness.waitForText("item", "Item A first"); + + await harness.navigate("/"); + await harness.waitForText("landing", "Landing"); + assert.equal( + harness.ledger.count( + "reference-release", + (event) => event.details.itemId === "A", + ), + 1, + harness.ledger.format(), + ); + + await act(async () => { + harness.historyBack(); + }); + const secondA = await harness.network.waitForRequest( + "LifecycleItemQuery", + "A", + 1, + ); + assert.notEqual(secondA.requestId, firstA.requestId); + await act(async () => { + harness.network.resolve(secondA, "Item A reloaded"); + }); + await harness.waitForText("item", "Item A reloaded"); + assert.equal( + harness.ledger.count( + "reference-create", + (event) => event.details.itemId === "A", + ), + 2, + harness.ledger.format(), + ); + } finally { + await harness.teardown(); + } +}); + +test("R1: a failed query is released and retry creates usable new work", async () => { + const harness = await createHarness({ initialEntry: "/items/R" }); + try { + const failed = await harness.network.waitForRequest( + "LifecycleItemQuery", + "R", + ); + expectConsoleOutput(/console\.warn: Warning: Error in route match: .*R/); + await act(async () => { + harness.network.reject(failed, "Expected route failure"); + }); + await harness.waitForText("route-error", "Expected route failure"); + const retryButton = harness.container.querySelector( + '[data-testid="retry"]', + ); + assert.ok(retryButton instanceof HTMLElement); + await act(async () => { + retryButton.click(); + }); + const retried = await harness.network.waitForRequest( + "LifecycleItemQuery", + "R", + 1, + ); + await act(async () => { + harness.network.resolve(retried, "Retry R"); + }); + await harness.waitForText("item", "Retry R"); + assert.equal( + harness.ledger.count( + "reference-release", + (event) => event.details.itemId === "R", + ), + 1, + harness.ledger.format(), + ); + assert.equal( + harness.ledger.count( + "reference-create", + (event) => event.details.itemId === "R", + ), + 2, + harness.ledger.format(), + ); + } finally { + await harness.teardown(); + } +}); diff --git a/tools/webapp-graphql-relay-lifecycle-fixture/test/multi.test.ts b/tools/webapp-graphql-relay-lifecycle-fixture/test/multi.test.ts new file mode 100644 index 0000000..e479d5c --- /dev/null +++ b/tools/webapp-graphql-relay-lifecycle-fixture/test/multi.test.ts @@ -0,0 +1,188 @@ +import assert from "node:assert/strict"; +import { act } from "react"; +import { describe, test } from "vitest"; +import { expectConsoleOutput } from "./console-guard.js"; +import { createHarness } from "./harness.js"; + +describe("M1/M2: two references render independently of completion order", () => { + for (const order of ["primary-first", "secondary-first"] as const) { + test(order, async () => { + const harness = await createHarness({ initialEntry: "/pairs/P" }); + try { + const primary = await harness.network.waitForRequest( + "LifecyclePrimaryQuery", + "P", + ); + const secondary = await harness.network.waitForRequest( + "LifecycleSecondaryQuery", + "P", + ); + const first = order === "primary-first" ? primary : secondary; + const second = order === "primary-first" ? secondary : primary; + await act(async () => { + harness.network.resolve(first); + }); + assert.equal(harness.ledger.count("reference-release"), 0); + await act(async () => { + harness.network.resolve(second); + }); + await harness.waitForText("pair", "LifecyclePrimaryQuery:P"); + await harness.waitForText("pair", "LifecycleSecondaryQuery:P"); + assert.equal(harness.ledger.count("reference-create"), 2); + assert.equal(harness.ledger.count("reference-release"), 0); + } finally { + await harness.teardown(); + } + }); + } +}); + +test("M3: abandoning a partially completed pair releases both references", async () => { + const harness = await createHarness({ initialEntry: "/pairs/M3" }); + try { + const primary = await harness.network.waitForRequest( + "LifecyclePrimaryQuery", + "M3", + ); + const secondary = await harness.network.waitForRequest( + "LifecycleSecondaryQuery", + "M3", + ); + await act(async () => { + harness.network.resolve(primary, "Primary complete"); + }); + await harness.navigate("/"); + await harness.waitForText("landing", "Landing"); + assert.equal( + harness.ledger.count( + "network-cancel", + (event) => event.details.requestId === secondary.requestId, + ), + 1, + harness.ledger.format(), + ); + assert.equal( + harness.ledger.count("reference-release"), + 2, + harness.ledger.format(), + ); + } finally { + await harness.teardown(); + } +}); + +test("M4: partial query failure retries with two fresh references", async () => { + const harness = await createHarness({ initialEntry: "/pairs/M4" }); + try { + const firstPrimary = await harness.network.waitForRequest( + "LifecyclePrimaryQuery", + "M4", + ); + const firstSecondary = await harness.network.waitForRequest( + "LifecycleSecondaryQuery", + "M4", + ); + expectConsoleOutput(/console\.warn: Warning: Error in route match: .*M4/); + await act(async () => { + harness.network.resolve(firstPrimary, "First primary"); + harness.network.reject(firstSecondary, "Expected pair failure"); + }); + await harness.waitForText("route-error", "Expected pair failure"); + const retryButton = harness.container.querySelector( + '[data-testid="retry"]', + ); + assert.ok(retryButton instanceof HTMLElement); + await act(async () => { + retryButton.click(); + }); + + const secondPrimary = await harness.network.waitForRequest( + "LifecyclePrimaryQuery", + "M4", + 1, + ); + const secondSecondary = await harness.network.waitForRequest( + "LifecycleSecondaryQuery", + "M4", + 1, + ); + await act(async () => { + harness.network.resolve(secondSecondary, "Second secondary"); + harness.network.resolve(secondPrimary, "Second primary"); + }); + await harness.waitForText("pair", "Second primary"); + await harness.waitForText("pair", "Second secondary"); + assert.equal( + harness.ledger.count("reference-create"), + 4, + harness.ledger.format(), + ); + assert.equal( + harness.ledger.count("reference-release"), + 2, + harness.ledger.format(), + ); + } finally { + await harness.teardown(); + } +}); + +test("M5: partial construction failure releases the first reference", async () => { + expectConsoleOutput(/console\.warn: Warning: Error in route match: .*M5/); + const harness = await createHarness({ + initialEntry: "/pairs/M5", + routerOptions: { failPairAfterPrimary: true }, + }); + try { + const primary = await harness.network.waitForRequest( + "LifecyclePrimaryQuery", + "M5", + ); + await harness.waitForText( + "route-error", + "Expected partial pair construction failure", + ); + assert.equal( + harness.ledger.count( + "network-cancel", + (event) => event.details.requestId === primary.requestId, + ), + 1, + harness.ledger.format(), + ); + assert.equal(harness.ledger.count("reference-create"), 1); + assert.equal(harness.ledger.count("reference-release"), 1); + assert.equal( + harness.network.requests("LifecycleSecondaryQuery", "M5").length, + 0, + ); + assert.equal(harness.ledger.count("react-caught-error"), 1); + } finally { + await harness.teardown(); + } +}); + +test("T1: final teardown releases every route-owned reference", async () => { + const harness = await createHarness({ initialEntry: "/pairs/T" }); + const primary = await harness.network.waitForRequest( + "LifecyclePrimaryQuery", + "T", + ); + const secondary = await harness.network.waitForRequest( + "LifecycleSecondaryQuery", + "T", + ); + await act(async () => { + harness.network.resolve(primary); + harness.network.resolve(secondary); + }); + await harness.waitForText("pair", "LifecycleSecondaryQuery:T"); + await harness.teardown(); + assert.equal(harness.ledger.count("reference-create"), 2); + assert.equal( + harness.ledger.count("reference-release"), + 2, + harness.ledger.format(), + ); + assert.equal(harness.network.pendingCount(), 0, harness.ledger.format()); +}); diff --git a/tools/webapp-graphql-relay-lifecycle-fixture/test/preload-cancel.test.ts b/tools/webapp-graphql-relay-lifecycle-fixture/test/preload-cancel.test.ts new file mode 100644 index 0000000..a9afcef --- /dev/null +++ b/tools/webapp-graphql-relay-lifecycle-fixture/test/preload-cancel.test.ts @@ -0,0 +1,35 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; +import { createHarness } from "./harness.js"; + +test("P3: clearing an unused preload cancels and releases it", async () => { + const harness = await createHarness({ initialEntry: "/" }); + try { + const preload = harness.preloadItem("P3"); + const request = await harness.network.waitForRequest( + "LifecycleItemQuery", + "P3", + ); + await preload; + harness.clearCache(); + assert.equal( + harness.ledger.count( + "network-cancel", + (event) => event.details.requestId === request.requestId, + ), + 1, + harness.ledger.format(), + ); + assert.equal( + harness.ledger.count( + "reference-release", + (event) => event.details.itemId === "P3", + ), + 1, + harness.ledger.format(), + ); + assert.doesNotMatch(harness.container.textContent ?? "", /P3/); + } finally { + await harness.teardown(); + } +}); diff --git a/tools/webapp-graphql-relay-lifecycle-fixture/test/preload-completed.test.ts b/tools/webapp-graphql-relay-lifecycle-fixture/test/preload-completed.test.ts new file mode 100644 index 0000000..bfc62b7 --- /dev/null +++ b/tools/webapp-graphql-relay-lifecycle-fixture/test/preload-completed.test.ts @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import { act } from "react"; +import { test } from "vitest"; +import { createHarness } from "./harness.js"; + +test("P2: a completed preload renders without a duplicate loader call", async () => { + const harness = await createHarness({ initialEntry: "/" }); + try { + const preload = harness.preloadItem("P2"); + const request = await harness.network.waitForRequest( + "LifecycleItemQuery", + "P2", + ); + await act(async () => { + harness.network.resolve(request, "Completed preload"); + }); + await preload; + + await harness.navigate("/items/P2"); + await harness.waitForText("item", "Completed preload"); + assert.equal( + harness.network.requests("LifecycleItemQuery", "P2").length, + 1, + harness.ledger.format(), + ); + assert.equal( + harness.ledger.count("reference-release"), + 0, + harness.ledger.format(), + ); + } finally { + await harness.teardown(); + } +}); diff --git a/tools/webapp-graphql-relay-lifecycle-fixture/test/preload.test.ts b/tools/webapp-graphql-relay-lifecycle-fixture/test/preload.test.ts new file mode 100644 index 0000000..9bb3ece --- /dev/null +++ b/tools/webapp-graphql-relay-lifecycle-fixture/test/preload.test.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import { act } from "react"; +import { test } from "vitest"; +import { createHarness } from "./harness.js"; + +test("P1: a pending Relay preload is reused by navigation", async () => { + const harness = await createHarness({ initialEntry: "/" }); + try { + const preload = harness.preloadItem("P"); + const request = await harness.network.waitForRequest( + "LifecycleItemQuery", + "P", + ); + await preload; + const { finished: navigation } = await harness.beginNavigation("/items/P"); + await act(async () => { + harness.network.resolve(request, "Preloaded P"); + }); + await navigation; + await harness.waitForText("item", "Preloaded P"); + assert.equal( + harness.network.requests("LifecycleItemQuery", "P").length, + 1, + harness.ledger.format(), + ); + assert.equal( + harness.ledger.count( + "reference-create", + (event) => event.details.itemId === "P", + ), + 1, + harness.ledger.format(), + ); + assert.equal( + harness.ledger.count( + "reference-release", + (event) => event.details.itemId === "P", + ), + 0, + harness.ledger.format(), + ); + } finally { + await harness.teardown(); + } + assert.equal( + harness.ledger.count( + "reference-release", + (event) => event.details.itemId === "P", + ), + 1, + harness.ledger.format(), + ); +}); diff --git a/tools/webapp-graphql-relay-lifecycle-fixture/test/recreate.test.ts b/tools/webapp-graphql-relay-lifecycle-fixture/test/recreate.test.ts new file mode 100644 index 0000000..1373501 --- /dev/null +++ b/tools/webapp-graphql-relay-lifecycle-fixture/test/recreate.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import { act } from "react"; +import { test } from "vitest"; +import { createHarness } from "./harness.js"; + +test("T2: recreating the runtime at one location does not reuse old identity", async () => { + const first = await createHarness({ initialEntry: "/items/T2" }); + const firstRequest = await first.network.waitForRequest( + "LifecycleItemQuery", + "T2", + ); + await act(async () => { + first.network.resolve(firstRequest, "First runtime"); + }); + await first.waitForText("item", "First runtime"); + const firstEnvironment = first.environment; + await first.teardown(); + assert.equal(first.ledger.count("reference-release"), 1); + + const second = await createHarness({ initialEntry: "/items/T2" }); + try { + const secondRequest = await second.network.waitForRequest( + "LifecycleItemQuery", + "T2", + ); + await act(async () => { + second.network.resolve(secondRequest, "Second runtime"); + }); + await second.waitForText("item", "Second runtime"); + assert.notEqual(second.environment, firstEnvironment); + assert.match(second.container.textContent ?? "", /Second runtime/); + assert.doesNotMatch(second.container.textContent ?? "", /First runtime/); + } finally { + await second.teardown(); + } +}); diff --git a/tools/webapp-graphql-relay-lifecycle-fixture/test/retry-supersede.test.ts b/tools/webapp-graphql-relay-lifecycle-fixture/test/retry-supersede.test.ts new file mode 100644 index 0000000..7604657 --- /dev/null +++ b/tools/webapp-graphql-relay-lifecycle-fixture/test/retry-supersede.test.ts @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import { act } from "react"; +import { test } from "vitest"; +import { createHarness } from "./harness.js"; + +test("R3: navigation supersedes retry work without a late commit", async () => { + const harness = await createHarness({ initialEntry: "/items/A" }); + try { + const original = await harness.network.waitForRequest( + "LifecycleItemQuery", + "A", + ); + await act(async () => { + harness.network.resolve(original, "Original A"); + }); + await harness.waitForText("item", "Original A"); + + let invalidation: Promise; + await act(async () => { + invalidation = harness.invalidate(); + }); + const retry = await harness.network.waitForRequest( + "LifecycleItemQuery", + "A", + 1, + ); + const { finished: navigation } = await harness.beginNavigation("/items/B"); + const requestB = await harness.network.waitForRequest( + "LifecycleItemQuery", + "B", + ); + await act(async () => { + harness.network.resolve(requestB, "Final B"); + }); + await Promise.all([invalidation!, navigation]); + await harness.waitForText("item", "Final B"); + + assert.equal( + harness.ledger.count( + "network-cancel", + (event) => event.details.requestId === retry.requestId, + ), + 1, + harness.ledger.format(), + ); + assert.throws(() => harness.network.resolve(retry, "Late retry")); + assert.match(harness.container.textContent ?? "", /Final B/); + } finally { + await harness.teardown(); + } +}); diff --git a/tools/webapp-graphql-relay-lifecycle-fixture/test/strict.test.ts b/tools/webapp-graphql-relay-lifecycle-fixture/test/strict.test.ts new file mode 100644 index 0000000..fc59293 --- /dev/null +++ b/tools/webapp-graphql-relay-lifecycle-fixture/test/strict.test.ts @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import { act } from "react"; +import { test } from "vitest"; +import { createHarness } from "./harness.js"; + +test("S1: Strict Mode does not duplicate initial loader ownership", async () => { + const harness = await createHarness({ + initialEntry: "/items/S1", + strictMode: true, + }); + try { + const request = await harness.network.waitForRequest( + "LifecycleItemQuery", + "S1", + ); + await act(async () => { + harness.network.resolve(request, "Strict single"); + }); + await harness.waitForText("item", "Strict single"); + assert.equal( + harness.network.requests("LifecycleItemQuery", "S1").length, + 1, + harness.ledger.format(), + ); + assert.equal(harness.ledger.count("reference-create"), 1); + assert.equal(harness.ledger.count("reference-release"), 0); + } finally { + await harness.teardown(); + } +}); + +test("S2: Strict Mode cannot release a pair before replacement commits", async () => { + const harness = await createHarness({ + initialEntry: "/pairs/S2", + strictMode: true, + }); + try { + const primary = await harness.network.waitForRequest( + "LifecyclePrimaryQuery", + "S2", + ); + const secondary = await harness.network.waitForRequest( + "LifecycleSecondaryQuery", + "S2", + ); + await act(async () => { + harness.network.resolve(secondary, "Strict secondary"); + harness.network.resolve(primary, "Strict primary"); + }); + await harness.waitForText("pair", "Strict primary"); + + const { finished: navigation } = + await harness.beginNavigation("/items/final"); + const finalRequest = await harness.network.waitForRequest( + "LifecycleItemQuery", + "final", + ); + assert.match(harness.container.textContent ?? "", /Strict primary/); + assert.equal( + harness.ledger.count("reference-release"), + 0, + harness.ledger.format(), + ); + await act(async () => { + harness.network.resolve(finalRequest, "Strict final"); + }); + await navigation; + await harness.waitForText("item", "Strict final"); + assert.equal( + harness.ledger.count("reference-create"), + 3, + harness.ledger.format(), + ); + assert.equal( + harness.ledger.count("reference-release"), + 2, + harness.ledger.format(), + ); + } finally { + await harness.teardown(); + } +}); diff --git a/tools/webapp-graphql-relay-lifecycle-fixture/tsconfig.json b/tools/webapp-graphql-relay-lifecycle-fixture/tsconfig.json new file mode 100644 index 0000000..a217dc8 --- /dev/null +++ b/tools/webapp-graphql-relay-lifecycle-fixture/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "jsx": "react-jsx", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": ".", + "skipLibCheck": true, + "strict": true, + "target": "ES2023", + "types": ["node"] + }, + "include": [ + "src/**/*.ts", + "src/**/*.tsx", + "browser-test/**/*.ts", + "test/**/*.ts", + "test/**/*.tsx", + "playwright.config.ts", + "vite.config.mts", + "vitest.config.mts" + ] +} diff --git a/tools/webapp-graphql-relay-lifecycle-fixture/vite.config.mts b/tools/webapp-graphql-relay-lifecycle-fixture/vite.config.mts new file mode 100644 index 0000000..cd18fb8 --- /dev/null +++ b/tools/webapp-graphql-relay-lifecycle-fixture/vite.config.mts @@ -0,0 +1,8 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + build: { + chunkSizeWarningLimit: 600, + outDir: "dist-browser", + }, +}); diff --git a/tools/webapp-graphql-relay-lifecycle-fixture/vitest.config.mts b/tools/webapp-graphql-relay-lifecycle-fixture/vitest.config.mts new file mode 100644 index 0000000..7b231fc --- /dev/null +++ b/tools/webapp-graphql-relay-lifecycle-fixture/vitest.config.mts @@ -0,0 +1,12 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "jsdom", + fileParallelism: false, + include: ["test/**/*.test.ts", "test/**/*.test.tsx"], + isolate: true, + setupFiles: ["./test/console-guard.ts"], + testTimeout: 10_000, + }, +}); diff --git a/website-docusaurus/docs-tree.yaml b/website-docusaurus/docs-tree.yaml index 3a9e742..7728436 100644 --- a/website-docusaurus/docs-tree.yaml +++ b/website-docusaurus/docs-tree.yaml @@ -19,6 +19,10 @@ items: source: ../docs/graphql-contract.md id: graphql-contract description: Server-owned schema and browser-owned Relay operations. + - title: Upgrade Webapp GraphQL Relay To 3.1 + source: ../docs/upgrades/webapp-graphql-relay-3.1.md + id: upgrades/webapp-graphql-relay-3-1 + description: Migrate mounted route queries to explicit lifetime ownership. - title: Auth And Sessions source: ../docs/auth-and-sessions.md id: auth-and-sessions