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