@ic-reactor/mcp 0.0.0-stage → 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/ISSUE_TEMPLATE/bug_report.yml +108 -0
- package/.github/ISSUE_TEMPLATE/config.yml +8 -0
- package/.github/ISSUE_TEMPLATE/feature_request.yml +77 -0
- package/.github/actions/setup-rust-wasm/action.yml +25 -0
- package/.github/dependabot.yml +107 -0
- package/.github/workflows/auth-latest.yml +67 -0
- package/.github/workflows/b3pay-site.yml +49 -0
- package/.github/workflows/ci.yml +430 -0
- package/.github/workflows/codeql.yml +41 -0
- package/.github/workflows/dependency-review.yml +68 -0
- package/.github/workflows/docs-check.yml +98 -0
- package/.github/workflows/docs.yml +265 -0
- package/.github/workflows/e2e.yml +90 -0
- package/.github/workflows/examples-published.yml +97 -0
- package/.github/workflows/release.yml +326 -0
- package/.husky/pre-commit +12 -0
- package/.prettierignore +109 -0
- package/.prettierrc +6 -0
- package/AGENTS.md +103 -0
- package/CHANGELOG.md +3268 -0
- package/CLAUDE.md +93 -0
- package/CNAME +1 -0
- package/CODE_OF_CONDUCT.md +7 -0
- package/CONTRIBUTING.md +433 -0
- package/LICENSE +21 -0
- package/README.md +2 -2
- package/SECURITY.md +74 -0
- package/b3pay-module.json +194 -0
- package/docs/.remarkrc.json +10 -0
- package/docs/README.md +64 -0
- package/docs/astro.config.mjs +205 -0
- package/docs/favicon.svg +15 -0
- package/docs/package.json +39 -0
- package/docs/public/favicon.svg +15 -0
- package/docs/public/ic-reactor-social-card.png +0 -0
- package/docs/scripts/b3pay-static.mjs +22 -0
- package/docs/scripts/check-example-docs.mjs +214 -0
- package/docs/scripts/check-links.mjs +212 -0
- package/docs/scripts/fix-dist-links.js +125 -0
- package/docs/scripts/fix-frontmatter.js +71 -0
- package/docs/scripts/remove-redundant-h1.cjs +48 -0
- package/docs/scripts/rewrite-typedoc-links.js +110 -0
- package/docs/scripts/split-index.js +69 -0
- package/docs/src/assets/ic-reactor-hero-dark.svg +23 -0
- package/docs/src/assets/ic-reactor-hero-light.svg +23 -0
- package/docs/src/assets/ic-reactor-lockup-dark.svg +15 -0
- package/docs/src/assets/ic-reactor-lockup-light.svg +15 -0
- package/docs/src/assets/ic-reactor-mark-dark.svg +14 -0
- package/docs/src/assets/ic-reactor-mark-light.svg +14 -0
- package/docs/src/assets/layers/candid-core-layer-icon-dark.svg +4 -0
- package/docs/src/assets/layers/candid-core-layer-icon-light.svg +4 -0
- package/docs/src/assets/layers/candid-profile-layer-icon-dark.svg +4 -0
- package/docs/src/assets/layers/candid-profile-layer-icon-light.svg +4 -0
- package/docs/src/assets/layers/ic-reactor-layer-icon-dark.svg +4 -0
- package/docs/src/assets/layers/ic-reactor-layer-icon-light.svg +4 -0
- package/docs/src/components/LibraryCards.astro +172 -0
- package/docs/src/components/LibraryIcon.astro +28 -0
- package/docs/src/components/SiteTitle.astro +241 -0
- package/docs/src/content/docs/404.mdx +19 -0
- package/docs/src/content/docs/build/agent-tools.mdx +593 -0
- package/docs/src/content/docs/build/icrc1-wallet.mdx +619 -0
- package/docs/src/content/docs/build/ssr-per-user.mdx +552 -0
- package/docs/src/content/docs/examples/canister-studio.mdx +95 -0
- package/docs/src/content/docs/examples/icrc-ledger.mdx +45 -0
- package/docs/src/content/docs/examples/index.mdx +38 -0
- package/docs/src/content/docs/examples/next-ssr.mdx +73 -0
- package/docs/src/content/docs/examples/node-agent-tool.mdx +80 -0
- package/docs/src/content/docs/examples/vite-wallet.mdx +71 -0
- package/docs/src/content/docs/guides/auth.mdx +1181 -0
- package/docs/src/content/docs/guides/client.mdx +551 -0
- package/docs/src/content/docs/guides/deploy.mdx +180 -0
- package/docs/src/content/docs/guides/errors.mdx +475 -0
- package/docs/src/content/docs/guides/getting-started.mdx +620 -0
- package/docs/src/content/docs/guides/reads.mdx +586 -0
- package/docs/src/content/docs/guides/ssr.mdx +529 -0
- package/docs/src/content/docs/guides/testing.mdx +401 -0
- package/docs/src/content/docs/guides/values.mdx +261 -0
- package/docs/src/content/docs/guides/vite-plugin.mdx +690 -0
- package/docs/src/content/docs/guides/writes.mdx +572 -0
- package/docs/src/content/docs/ic-reactor/index.mdx +167 -0
- package/docs/src/content/docs/index.mdx +65 -0
- package/docs/src/content/docs/migrating-from-3.mdx +535 -0
- package/docs/src/content/docs/packages/core.mdx +186 -0
- package/docs/src/content/docs/packages/mcp.mdx +163 -0
- package/docs/src/content/docs/packages/react.mdx +207 -0
- package/docs/src/content/docs/packages/vite-plugin.mdx +73 -0
- package/docs/src/content/docs/versioning.mdx +104 -0
- package/docs/src/content.config.ts +7 -0
- package/docs/src/styles/custom.css +189 -0
- package/docs/tsconfig.typedoc.json +26 -0
- package/docs/typedoc-entry.ts +17 -0
- package/docs/typedoc.json +37 -0
- package/e2e/Cargo.lock +646 -0
- package/e2e/Cargo.toml +3 -0
- package/e2e/README.md +101 -0
- package/e2e/frontends/build.mjs +94 -0
- package/e2e/frontends/index.html +10 -0
- package/e2e/global-setup.ts +65 -0
- package/e2e/icp.yaml +38 -0
- package/e2e/package.json +40 -0
- package/e2e/setup.ts +6 -0
- package/e2e/src/actor/Cargo.toml +14 -0
- package/e2e/src/actor/hello_actor.did +10 -0
- package/e2e/src/actor/src/lib.rs +73 -0
- package/e2e/src/calls.test.ts +132 -0
- package/e2e/src/declarations/hello_actor.envelope.json +175 -0
- package/e2e/src/declarations/hello_actor.ts +15 -0
- package/e2e/src/errors.test.ts +107 -0
- package/e2e/src/frontend-headers.test.ts +253 -0
- package/e2e/src/identity.test.ts +61 -0
- package/e2e/src/react.test.tsx +59 -0
- package/e2e/src/replica.ts +70 -0
- package/e2e/src/vite-env.test.ts +108 -0
- package/e2e/test.sh +47 -0
- package/e2e/tsconfig.json +20 -0
- package/e2e/tsconfig.typecheck.json +9 -0
- package/e2e/vitest.config.ts +22 -0
- package/eslint.config.mjs +100 -0
- package/evals/PREREGISTRATION.md +1346 -0
- package/evals/README.md +756 -0
- package/evals/conditions/thin/condition.json +8 -0
- package/evals/conditions/thin/docs/candid-core-cli.README.md +90 -0
- package/evals/conditions/thin/docs/candid-core-schema.README.md +339 -0
- package/evals/conditions/thin/package.json +25 -0
- package/evals/conditions/thin/starter/src/generated/icrc1.ts +57 -0
- package/evals/conditions/thin-guide/condition.json +10 -0
- package/evals/conditions/thin-guide/docs/llms.txt +292 -0
- package/evals/conditions/v3/condition.json +9 -0
- package/evals/conditions/v3/docs/ic-reactor-core.llms.txt +232 -0
- package/evals/conditions/v3/docs/ic-reactor-react.llms.txt +280 -0
- package/evals/conditions/v3/docs/llms-full.txt +1053 -0
- package/evals/conditions/v3/package.json +26 -0
- package/evals/conditions/v3/starter/src/declarations/icrc1.did.d.ts +95 -0
- package/evals/conditions/v3/starter/src/declarations/icrc1.did.js +140 -0
- package/evals/conditions/v4/PORTING.md +384 -0
- package/evals/conditions/v4/README.md +106 -0
- package/evals/conditions/v4/condition.json +5 -0
- package/evals/conditions/v4/docs/llms.txt +323 -0
- package/evals/conditions/v4/package.json +27 -0
- package/evals/conditions/v4/starter/src/generated/icrc1.ts +78 -0
- package/evals/conditions/v4-proto/condition.json +6 -0
- package/evals/conditions/v4-proto/docs/llms.txt +291 -0
- package/evals/conditions/v4-proto/lib/SHIM-NOTES.md +20 -0
- package/evals/conditions/v4-proto/lib/package.json +27 -0
- package/evals/conditions/v4-proto/lib/src/client.ts +471 -0
- package/evals/conditions/v4-proto/lib/src/errors.ts +214 -0
- package/evals/conditions/v4-proto/lib/src/index.ts +36 -0
- package/evals/conditions/v4-proto/lib/src/principal.ts +175 -0
- package/evals/conditions/v4-proto/lib/src/service.ts +69 -0
- package/evals/conditions/v4-proto/lib/src/types.ts +174 -0
- package/evals/conditions/v4-proto/lib/test/runtime.test.ts +170 -0
- package/evals/conditions/v4-proto/lib/test/tsconfig.json +10 -0
- package/evals/conditions/v4-proto/lib/test/types.test-d.ts +145 -0
- package/evals/conditions/v4-proto/lib/test/vitest.config.mjs +8 -0
- package/evals/conditions/v4-proto/lib/tsconfig.json +17 -0
- package/evals/conditions/v4-proto/package.json +26 -0
- package/evals/conditions/v4-proto/starter/src/generated/icrc1.service.ts +19 -0
- package/evals/conditions/v4-proto/starter/src/generated/icrc1.ts +57 -0
- package/evals/drive.mjs +1221 -0
- package/evals/gate.mjs +161 -0
- package/evals/harness/aggregate.mjs +534 -0
- package/evals/harness/aggregate.test.mjs +344 -0
- package/evals/harness/assemble.mjs +216 -0
- package/evals/harness/assemble.test.mjs +143 -0
- package/evals/harness/auth.mjs +91 -0
- package/evals/harness/check-docs.mjs +157 -0
- package/evals/harness/declarations/icrc1.did.d.ts +95 -0
- package/evals/harness/declarations/icrc1.did.js +140 -0
- package/evals/harness/drive-auth.test.mjs +584 -0
- package/evals/harness/drive.test.mjs +509 -0
- package/evals/harness/fake-auth.ts +67 -0
- package/evals/harness/fake-ledger.ts +178 -0
- package/evals/harness/gate-plan.mjs +104 -0
- package/evals/harness/gate-plan.test.mjs +243 -0
- package/evals/harness/icrc1.did +88 -0
- package/evals/harness/judge.mjs +63 -0
- package/evals/harness/judge.test.mjs +81 -0
- package/evals/harness/leak-scan.mjs +1373 -0
- package/evals/harness/leak-scan.test.mjs +1300 -0
- package/evals/harness/probes/amounts.ts +14 -0
- package/evals/harness/probes/node-tool.probe.ts +48 -0
- package/evals/harness/probes/probe.mjs +149 -0
- package/evals/harness/probes/react-wallet.probe.ts +96 -0
- package/evals/harness/public/support/auth.ts +38 -0
- package/evals/harness/public/support/fake-replica.d.ts +28 -0
- package/evals/harness/public/support/fake-replica.js +434 -0
- package/evals/harness/public/support/ledger.idl.d.ts +95 -0
- package/evals/harness/public/support/ledger.idl.js +140 -0
- package/evals/harness/public/support/world.ts +179 -0
- package/evals/harness/runs.mjs +139 -0
- package/evals/harness/sandbox.mjs +88 -0
- package/evals/harness/sandbox.test.mjs +134 -0
- package/evals/harness/ship.mjs +264 -0
- package/evals/harness/ship.test.mjs +552 -0
- package/evals/harness/world.ts +291 -0
- package/evals/package.json +26 -0
- package/evals/pnpm-lock.yaml +1537 -0
- package/evals/pnpm-workspace.yaml +12 -0
- package/evals/results/2026-09-30T14-22-05-924Z/pilot.json +384 -0
- package/evals/results/2026-09-30T14-22-05-924Z/pilot.rescanned.json +404 -0
- package/evals/results/2026-09-30T14-22-05-924Z/pilot.rescanned.rescored.json +404 -0
- package/evals/results/2026-09-30T14-22-05-924Z/plan.json +240 -0
- package/evals/results/2026-09-30T14-22-05-924Z/summary.json +1800 -0
- package/evals/results/2026-09-30T14-22-05-924Z/summary.rescanned.json +1843 -0
- package/evals/results/2026-09-30T14-22-05-924Z/summary.rescanned.rescored.json +2100 -0
- package/evals/results/2026-09-30T15-48-23-343Z/pilot.json +404 -0
- package/evals/results/2026-09-30T15-48-23-343Z/pilot.rescored.json +404 -0
- package/evals/results/2026-09-30T15-48-23-343Z/plan.json +242 -0
- package/evals/results/2026-09-30T15-48-23-343Z/summary.json +1843 -0
- package/evals/results/2026-09-30T15-48-23-343Z/summary.rescored.json +2195 -0
- package/evals/results/2026-10-03-beta1-gate/pilot.json +162 -0
- package/evals/results/2026-10-03-beta1-gate/plan.json +141 -0
- package/evals/results/2026-10-03-beta1-gate/summary.json +983 -0
- package/evals/results/2026-10-05-ga-gate/contaminated-run.json +55 -0
- package/evals/results/2026-10-05-ga-gate/pilot.json +162 -0
- package/evals/results/2026-10-05-ga-gate/plan.json +457 -0
- package/evals/results/2026-10-05-ga-gate/summary.json +1037 -0
- package/evals/results/2026-10-05-ga-gate/summary.rescanned.json +1037 -0
- package/evals/results/README.md +31 -0
- package/evals/runs-pilot.log +150 -0
- package/evals/runs-pilot2.log +150 -0
- package/evals/score.mjs +256 -0
- package/evals/setup.mjs +547 -0
- package/evals/tasks/node-tool/faulty/thin-accepts-exponent/meta.json +5 -0
- package/evals/tasks/node-tool/faulty/thin-accepts-exponent/src/index.ts +121 -0
- package/evals/tasks/node-tool/faulty/thin-anonymous-transfer/meta.json +5 -0
- package/evals/tasks/node-tool/faulty/thin-anonymous-transfer/src/index.ts +116 -0
- package/evals/tasks/node-tool/faulty/thin-every-reject-may-have-executed/meta.json +9 -0
- package/evals/tasks/node-tool/faulty/thin-every-reject-may-have-executed/src/index.ts +118 -0
- package/evals/tasks/node-tool/faulty/thin-fetches-root-key-off-local/meta.json +5 -0
- package/evals/tasks/node-tool/faulty/thin-fetches-root-key-off-local/src/index.ts +118 -0
- package/evals/tasks/node-tool/faulty/thin-ignores-root-key/meta.json +5 -0
- package/evals/tasks/node-tool/faulty/thin-ignores-root-key/src/index.ts +117 -0
- package/evals/tasks/node-tool/faulty/thin-misclassifies-reject-codes/meta.json +9 -0
- package/evals/tasks/node-tool/faulty/thin-misclassifies-reject-codes/src/index.ts +119 -0
- package/evals/tasks/node-tool/faulty/thin-no-nat64-cap/meta.json +9 -0
- package/evals/tasks/node-tool/faulty/thin-no-nat64-cap/src/index.ts +118 -0
- package/evals/tasks/node-tool/faulty/thin-number-amount/meta.json +10 -0
- package/evals/tasks/node-tool/faulty/thin-number-amount/src/index.ts +117 -0
- package/evals/tasks/node-tool/faulty/thin-truncates-fraction-digits/meta.json +5 -0
- package/evals/tasks/node-tool/faulty/thin-truncates-fraction-digits/src/index.ts +120 -0
- package/evals/tasks/node-tool/faulty/thin-unvalidated-recipient/meta.json +5 -0
- package/evals/tasks/node-tool/faulty/thin-unvalidated-recipient/src/index.ts +118 -0
- package/evals/tasks/node-tool/faulty/v3-retry-call-errors/meta.json +11 -0
- package/evals/tasks/node-tool/faulty/v3-retry-call-errors/src/index.ts +149 -0
- package/evals/tasks/node-tool/faulty/v4-anonymous-identity-sent/meta.json +5 -0
- package/evals/tasks/node-tool/faulty/v4-anonymous-identity-sent/src/index.ts +71 -0
- package/evals/tasks/node-tool/faulty/v4-never-may-have-executed/meta.json +9 -0
- package/evals/tasks/node-tool/faulty/v4-never-may-have-executed/src/index.ts +84 -0
- package/evals/tasks/node-tool/faulty/v4-proto-never-may-have-executed/meta.json +9 -0
- package/evals/tasks/node-tool/faulty/v4-proto-never-may-have-executed/src/index.ts +72 -0
- package/evals/tasks/node-tool/faulty/v4-proto-refuses-nat64-max/meta.json +5 -0
- package/evals/tasks/node-tool/faulty/v4-proto-refuses-nat64-max/src/index.ts +72 -0
- package/evals/tasks/node-tool/faulty/v4-refuses-nat64-max/meta.json +5 -0
- package/evals/tasks/node-tool/faulty/v4-refuses-nat64-max/src/index.ts +84 -0
- package/evals/tasks/node-tool/hidden/node-tool.test.ts +312 -0
- package/evals/tasks/node-tool/prompt.md +55 -0
- package/evals/tasks/node-tool/prompt.minimal.md +30 -0
- package/evals/tasks/node-tool/solutions/thin/reference/src/index.ts +118 -0
- package/evals/tasks/node-tool/solutions/thin/reference-module-scope/src/index.ts +145 -0
- package/evals/tasks/node-tool/solutions/thin-guide/reference/src/index.ts +118 -0
- package/evals/tasks/node-tool/solutions/thin-guide/reference-module-scope/src/index.ts +145 -0
- package/evals/tasks/node-tool/solutions/v3/reference/src/index.ts +140 -0
- package/evals/tasks/node-tool/solutions/v3/reference-module-scope/src/index.ts +153 -0
- package/evals/tasks/node-tool/solutions/v4/reference/src/index.ts +84 -0
- package/evals/tasks/node-tool/solutions/v4/reference-module-scope/src/index.ts +105 -0
- package/evals/tasks/node-tool/solutions/v4-proto/reference/src/index.ts +72 -0
- package/evals/tasks/node-tool/solutions/v4-proto/reference-module-scope/src/index.ts +87 -0
- package/evals/tasks/node-tool/starter/src/contract.ts +31 -0
- package/evals/tasks/node-tool/starter/src/index.ts +7 -0
- package/evals/tasks/node-tool/starter/test/smoke.test.ts +22 -0
- package/evals/tasks/node-tool/starter/tsconfig.json +17 -0
- package/evals/tasks/node-tool/task.json +63 -0
- package/evals/tasks/react-wallet/faulty/thin-accepts-exponent/meta.json +5 -0
- package/evals/tasks/react-wallet/faulty/thin-accepts-exponent/src/Wallet.tsx +216 -0
- package/evals/tasks/react-wallet/faulty/thin-anonymous-transfer/meta.json +5 -0
- package/evals/tasks/react-wallet/faulty/thin-anonymous-transfer/src/Wallet.tsx +212 -0
- package/evals/tasks/react-wallet/faulty/thin-both-auth-buttons/meta.json +5 -0
- package/evals/tasks/react-wallet/faulty/thin-both-auth-buttons/src/Wallet.tsx +210 -0
- package/evals/tasks/react-wallet/faulty/thin-constant-balance-key/meta.json +8 -0
- package/evals/tasks/react-wallet/faulty/thin-constant-balance-key/src/Wallet.tsx +213 -0
- package/evals/tasks/react-wallet/faulty/thin-no-nat64-cap/meta.json +9 -0
- package/evals/tasks/react-wallet/faulty/thin-no-nat64-cap/src/Wallet.tsx +213 -0
- package/evals/tasks/react-wallet/faulty/thin-number-balance/meta.json +5 -0
- package/evals/tasks/react-wallet/faulty/thin-number-balance/src/Wallet.tsx +213 -0
- package/evals/tasks/react-wallet/faulty/thin-transfer-as-query/meta.json +5 -0
- package/evals/tasks/react-wallet/faulty/thin-transfer-as-query/src/Wallet.tsx +254 -0
- package/evals/tasks/react-wallet/faulty/thin-truncates-fraction-digits/meta.json +5 -0
- package/evals/tasks/react-wallet/faulty/thin-truncates-fraction-digits/src/Wallet.tsx +215 -0
- package/evals/tasks/react-wallet/faulty/thin-unguarded-balance-effect/meta.json +5 -0
- package/evals/tasks/react-wallet/faulty/thin-unguarded-balance-effect/src/Wallet.tsx +214 -0
- package/evals/tasks/react-wallet/faulty/v3-http-4xx-unknown/meta.json +5 -0
- package/evals/tasks/react-wallet/faulty/v3-http-4xx-unknown/src/Wallet.tsx +224 -0
- package/evals/tasks/react-wallet/faulty/v3-mutation-retry-3/meta.json +12 -0
- package/evals/tasks/react-wallet/faulty/v3-mutation-retry-3/src/Wallet.tsx +225 -0
- package/evals/tasks/react-wallet/faulty/v3-unknown-as-error/meta.json +11 -0
- package/evals/tasks/react-wallet/faulty/v3-unknown-as-error/src/Wallet.tsx +182 -0
- package/evals/tasks/react-wallet/faulty/v4-every-reject-unknown/meta.json +5 -0
- package/evals/tasks/react-wallet/faulty/v4-every-reject-unknown/src/Wallet.tsx +160 -0
- package/evals/tasks/react-wallet/faulty/v4-keep-previous-data/meta.json +5 -0
- package/evals/tasks/react-wallet/faulty/v4-keep-previous-data/src/Wallet.tsx +166 -0
- package/evals/tasks/react-wallet/faulty/v4-proto-every-reject-unknown/meta.json +5 -0
- package/evals/tasks/react-wallet/faulty/v4-proto-every-reject-unknown/src/Wallet.tsx +161 -0
- package/evals/tasks/react-wallet/faulty/v4-proto-keep-previous-data/meta.json +5 -0
- package/evals/tasks/react-wallet/faulty/v4-proto-keep-previous-data/src/Wallet.tsx +163 -0
- package/evals/tasks/react-wallet/faulty/v4-proto-retry-spread/meta.json +12 -0
- package/evals/tasks/react-wallet/faulty/v4-proto-retry-spread/src/Wallet.tsx +162 -0
- package/evals/tasks/react-wallet/faulty/v4-proto-status-not-idle/meta.json +5 -0
- package/evals/tasks/react-wallet/faulty/v4-proto-status-not-idle/src/Wallet.tsx +163 -0
- package/evals/tasks/react-wallet/faulty/v4-retry-spread/meta.json +12 -0
- package/evals/tasks/react-wallet/faulty/v4-retry-spread/src/Wallet.tsx +163 -0
- package/evals/tasks/react-wallet/faulty/v4-status-not-idle/meta.json +5 -0
- package/evals/tasks/react-wallet/faulty/v4-status-not-idle/src/Wallet.tsx +162 -0
- package/evals/tasks/react-wallet/hidden/react-wallet.test.ts +536 -0
- package/evals/tasks/react-wallet/prompt.md +88 -0
- package/evals/tasks/react-wallet/prompt.minimal.md +51 -0
- package/evals/tasks/react-wallet/solutions/thin/reference/src/Wallet.tsx +213 -0
- package/evals/tasks/react-wallet/solutions/thin/reference-module-scope/src/Wallet.tsx +227 -0
- package/evals/tasks/react-wallet/solutions/thin-guide/reference/src/Wallet.tsx +213 -0
- package/evals/tasks/react-wallet/solutions/thin-guide/reference-module-scope/src/Wallet.tsx +227 -0
- package/evals/tasks/react-wallet/solutions/v3/reference/src/Wallet.tsx +224 -0
- package/evals/tasks/react-wallet/solutions/v3/reference-module-scope/src/Wallet.tsx +230 -0
- package/evals/tasks/react-wallet/solutions/v4/reference/src/Wallet.tsx +160 -0
- package/evals/tasks/react-wallet/solutions/v4/reference-module-scope/src/Wallet.tsx +178 -0
- package/evals/tasks/react-wallet/solutions/v4-proto/reference/src/Wallet.tsx +161 -0
- package/evals/tasks/react-wallet/solutions/v4-proto/reference-module-scope/src/Wallet.tsx +161 -0
- package/evals/tasks/react-wallet/starter/src/Wallet.tsx +12 -0
- package/evals/tasks/react-wallet/starter/src/auth.ts +16 -0
- package/evals/tasks/react-wallet/starter/src/config.ts +14 -0
- package/evals/tasks/react-wallet/starter/src/ii-auth.ts +43 -0
- package/evals/tasks/react-wallet/starter/src/main.tsx +15 -0
- package/evals/tasks/react-wallet/starter/test/smoke.test.ts +34 -0
- package/evals/tasks/react-wallet/starter/tsconfig.json +18 -0
- package/evals/tasks/react-wallet/task.json +84 -0
- package/examples/canister-studio/.stackblitzrc +4 -0
- package/examples/canister-studio/README.md +183 -0
- package/examples/canister-studio/did/ckbtc_minter.did +806 -0
- package/examples/canister-studio/did/icp_ledger.did +586 -0
- package/examples/canister-studio/did/icrc1.did +88 -0
- package/examples/canister-studio/package.json +45 -0
- package/examples/canister-studio/scripts/smoke.ts +188 -0
- package/examples/canister-studio/src/calls/CacheKeyPanel.tsx +104 -0
- package/examples/canister-studio/src/calls/ConfirmWrite.tsx +265 -0
- package/examples/canister-studio/src/calls/ErrorPanel.test.tsx +113 -0
- package/examples/canister-studio/src/calls/ErrorPanel.tsx +214 -0
- package/examples/canister-studio/src/calls/MethodRunner.test.tsx +354 -0
- package/examples/canister-studio/src/calls/MethodRunner.tsx +57 -0
- package/examples/canister-studio/src/calls/ReadRunner.tsx +244 -0
- package/examples/canister-studio/src/calls/ResendPanel.tsx +165 -0
- package/examples/canister-studio/src/calls/ShareLink.tsx +59 -0
- package/examples/canister-studio/src/calls/WriteRunner.test.tsx +678 -0
- package/examples/canister-studio/src/calls/WriteRunner.tsx +670 -0
- package/examples/canister-studio/src/calls/dedupe.ts +189 -0
- package/examples/canister-studio/src/calls/error-info.ts +168 -0
- package/examples/canister-studio/src/calls/history.test.ts +284 -0
- package/examples/canister-studio/src/calls/history.ts +287 -0
- package/examples/canister-studio/src/calls/kinds.ts +68 -0
- package/examples/canister-studio/src/calls/prepare.ts +32 -0
- package/examples/canister-studio/src/calls/query-options.ts +30 -0
- package/examples/canister-studio/src/calls/read.ts +47 -0
- package/examples/canister-studio/src/calls/readback.ts +100 -0
- package/examples/canister-studio/src/calls/record.ts +123 -0
- package/examples/canister-studio/src/calls/safe-calls.test.ts +299 -0
- package/examples/canister-studio/src/calls/share.ts +46 -0
- package/examples/canister-studio/src/calls/testing/doubles.tsx +108 -0
- package/examples/canister-studio/src/calls/testing/fixtures.ts +185 -0
- package/examples/canister-studio/src/calls/useCanisterHandle.ts +35 -0
- package/examples/canister-studio/src/canisters/ckbtc_minter.envelope.json +4228 -0
- package/examples/canister-studio/src/canisters/ckbtc_minter.ts +868 -0
- package/examples/canister-studio/src/canisters/icp_ledger.envelope.json +3454 -0
- package/examples/canister-studio/src/canisters/icp_ledger.ts +584 -0
- package/examples/canister-studio/src/canisters/icrc1.envelope.json +840 -0
- package/examples/canister-studio/src/canisters/icrc1.ts +78 -0
- package/examples/canister-studio/src/components/ActionButton.tsx +32 -0
- package/examples/canister-studio/src/components/AppHeader.tsx +83 -0
- package/examples/canister-studio/src/components/AppShell.tsx +29 -0
- package/examples/canister-studio/src/components/AuthButton.tsx +79 -0
- package/examples/canister-studio/src/components/Badge.tsx +79 -0
- package/examples/canister-studio/src/components/CanisterHeader.tsx +200 -0
- package/examples/canister-studio/src/components/CanisterJump.tsx +130 -0
- package/examples/canister-studio/src/components/Card.tsx +64 -0
- package/examples/canister-studio/src/components/Code.tsx +101 -0
- package/examples/canister-studio/src/components/CopyButton.tsx +126 -0
- package/examples/canister-studio/src/components/Disclosure.tsx +34 -0
- package/examples/canister-studio/src/components/EmptyState.tsx +34 -0
- package/examples/canister-studio/src/components/MethodList.test.tsx +137 -0
- package/examples/canister-studio/src/components/MethodList.tsx +244 -0
- package/examples/canister-studio/src/components/Notice.tsx +36 -0
- package/examples/canister-studio/src/components/ProvenanceBadge.tsx +44 -0
- package/examples/canister-studio/src/components/RouteError.tsx +42 -0
- package/examples/canister-studio/src/components/SectionBoundary.tsx +76 -0
- package/examples/canister-studio/src/components/Spinner.tsx +33 -0
- package/examples/canister-studio/src/components/Tabs.tsx +96 -0
- package/examples/canister-studio/src/components/canister/CanisterLoadFailure.test.tsx +56 -0
- package/examples/canister-studio/src/components/canister/CanisterLoadFailure.tsx +165 -0
- package/examples/canister-studio/src/components/canister/CanisterNotFound.tsx +59 -0
- package/examples/canister-studio/src/components/canister/CanisterPending.tsx +33 -0
- package/examples/canister-studio/src/components/canister/MethodOverview.tsx +93 -0
- package/examples/canister-studio/src/components/canister/MethodWorkspace.test.tsx +201 -0
- package/examples/canister-studio/src/components/canister/MethodWorkspace.tsx +187 -0
- package/examples/canister-studio/src/components/canister/shared-call.test.ts +186 -0
- package/examples/canister-studio/src/components/canister/shared-call.ts +133 -0
- package/examples/canister-studio/src/components/canister/shared-link-loader.test.ts +116 -0
- package/examples/canister-studio/src/components/canister/use-canister-page.ts +44 -0
- package/examples/canister-studio/src/components/canister/workspace-key.test.ts +27 -0
- package/examples/canister-studio/src/components/canister/workspace-key.ts +36 -0
- package/examples/canister-studio/src/components/canister-id-input.ts +69 -0
- package/examples/canister-studio/src/components/canister-names.ts +11 -0
- package/examples/canister-studio/src/components/download.ts +34 -0
- package/examples/canister-studio/src/components/fallbacks.tsx +33 -0
- package/examples/canister-studio/src/components/helpers.test.ts +107 -0
- package/examples/canister-studio/src/components/history/HistoryList.tsx +284 -0
- package/examples/canister-studio/src/components/home/FeaturedLedger.tsx +149 -0
- package/examples/canister-studio/src/components/home/OpenCanister.tsx +99 -0
- package/examples/canister-studio/src/components/home/featured.test.tsx +92 -0
- package/examples/canister-studio/src/components/home/featured.ts +64 -0
- package/examples/canister-studio/src/components/interface/CandidTab.tsx +38 -0
- package/examples/canister-studio/src/components/interface/CompatReport.tsx +92 -0
- package/examples/canister-studio/src/components/interface/CompatibilityTab.tsx +190 -0
- package/examples/canister-studio/src/components/interface/ProjectTab.tsx +196 -0
- package/examples/canister-studio/src/components/interface/TypeScriptTab.tsx +114 -0
- package/examples/canister-studio/src/components/interface/tools.test.tsx +169 -0
- package/examples/canister-studio/src/components/modes.ts +31 -0
- package/examples/canister-studio/src/components/playground/MethodPicker.tsx +67 -0
- package/examples/canister-studio/src/components/playground/PlaygroundForms.tsx +112 -0
- package/examples/canister-studio/src/components/playground/PlaygroundTools.tsx +219 -0
- package/examples/canister-studio/src/components/playground/sample.ts +43 -0
- package/examples/canister-studio/src/components/playground/target.test.tsx +86 -0
- package/examples/canister-studio/src/components/primitives.test.tsx +121 -0
- package/examples/canister-studio/src/components/provenance.ts +38 -0
- package/examples/canister-studio/src/components/route-render.test.tsx +71 -0
- package/examples/canister-studio/src/components/use-task.ts +56 -0
- package/examples/canister-studio/src/components/wallet/BalancePanel.tsx +110 -0
- package/examples/canister-studio/src/components/wallet/LedgerSummary.tsx +63 -0
- package/examples/canister-studio/src/components/wallet/MinterPanel.tsx +255 -0
- package/examples/canister-studio/src/components/wallet/SendPanel.test.tsx +297 -0
- package/examples/canister-studio/src/components/wallet/SendPanel.tsx +446 -0
- package/examples/canister-studio/src/components/wallet/ledgers.test.ts +36 -0
- package/examples/canister-studio/src/components/wallet/ledgers.ts +111 -0
- package/examples/canister-studio/src/forms/JsonPanel.tsx +157 -0
- package/examples/canister-studio/src/forms/MethodForm.test.tsx +369 -0
- package/examples/canister-studio/src/forms/MethodForm.tsx +368 -0
- package/examples/canister-studio/src/forms/TypedForm.tsx +55 -0
- package/examples/canister-studio/src/forms/context.ts +62 -0
- package/examples/canister-studio/src/forms/convert.ts +131 -0
- package/examples/canister-studio/src/forms/edge.test.ts +252 -0
- package/examples/canister-studio/src/forms/engine.test.ts +358 -0
- package/examples/canister-studio/src/forms/engine.ts +1492 -0
- package/examples/canister-studio/src/forms/fields/BytesField.tsx +210 -0
- package/examples/canister-studio/src/forms/fields/Field.tsx +106 -0
- package/examples/canister-studio/src/forms/fields/FieldShell.tsx +101 -0
- package/examples/canister-studio/src/forms/fields/Group.tsx +74 -0
- package/examples/canister-studio/src/forms/fields/ListField.tsx +256 -0
- package/examples/canister-studio/src/forms/fields/Segmented.tsx +78 -0
- package/examples/canister-studio/src/forms/fields/TimestampField.tsx +108 -0
- package/examples/canister-studio/src/forms/fields/UnitsField.tsx +119 -0
- package/examples/canister-studio/src/forms/fields/composites.tsx +389 -0
- package/examples/canister-studio/src/forms/fields/leaves.tsx +350 -0
- package/examples/canister-studio/src/forms/fields.test.tsx +396 -0
- package/examples/canister-studio/src/forms/helpers.test.ts +225 -0
- package/examples/canister-studio/src/forms/hints.ts +128 -0
- package/examples/canister-studio/src/forms/json.ts +119 -0
- package/examples/canister-studio/src/forms/parsing.test.ts +217 -0
- package/examples/canister-studio/src/forms/useMethodForm.ts +251 -0
- package/examples/canister-studio/src/interface/browser.test.ts +23 -0
- package/examples/canister-studio/src/interface/browser.ts +153 -0
- package/examples/canister-studio/src/interface/cache.ts +68 -0
- package/examples/canister-studio/src/interface/cli-cache.test.ts +155 -0
- package/examples/canister-studio/src/interface/cli-cache.ts +125 -0
- package/examples/canister-studio/src/interface/compile.server.test.ts +198 -0
- package/examples/canister-studio/src/interface/compile.server.ts +262 -0
- package/examples/canister-studio/src/interface/compile.test.ts +108 -0
- package/examples/canister-studio/src/interface/compile.ts +305 -0
- package/examples/canister-studio/src/interface/docs.test.ts +75 -0
- package/examples/canister-studio/src/interface/docs.ts +100 -0
- package/examples/canister-studio/src/interface/functions.test.ts +42 -0
- package/examples/canister-studio/src/interface/functions.ts +200 -0
- package/examples/canister-studio/src/interface/live-candid.server.ts +91 -0
- package/examples/canister-studio/src/interface/live-loader.test.ts +515 -0
- package/examples/canister-studio/src/interface/live-loader.ts +364 -0
- package/examples/canister-studio/src/interface/load.test.ts +328 -0
- package/examples/canister-studio/src/interface/load.ts +249 -0
- package/examples/canister-studio/src/interface/read-candid.test.ts +185 -0
- package/examples/canister-studio/src/interface/read-candid.ts +233 -0
- package/examples/canister-studio/src/interface/server-result.ts +42 -0
- package/examples/canister-studio/src/interface/typed.ts +68 -0
- package/examples/canister-studio/src/interface/types.ts +121 -0
- package/examples/canister-studio/src/lib/format.test.ts +107 -0
- package/examples/canister-studio/src/lib/format.ts +139 -0
- package/examples/canister-studio/src/lib/hex.test.ts +43 -0
- package/examples/canister-studio/src/lib/hex.ts +33 -0
- package/examples/canister-studio/src/lib/ids.test.ts +70 -0
- package/examples/canister-studio/src/lib/ids.ts +93 -0
- package/examples/canister-studio/src/lib/reactor.test.ts +55 -0
- package/examples/canister-studio/src/lib/reactor.ts +55 -0
- package/examples/canister-studio/src/lib/registry.test.ts +74 -0
- package/examples/canister-studio/src/lib/registry.ts +238 -0
- package/examples/canister-studio/src/lib/schema.ts +148 -0
- package/examples/canister-studio/src/routeTree.gen.ts +227 -0
- package/examples/canister-studio/src/router.test.ts +290 -0
- package/examples/canister-studio/src/router.tsx +127 -0
- package/examples/canister-studio/src/routes/__root.tsx +58 -0
- package/examples/canister-studio/src/routes/canister.$canisterId.index.tsx +172 -0
- package/examples/canister-studio/src/routes/canister.$canisterId.interface.tsx +98 -0
- package/examples/canister-studio/src/routes/canister.$canisterId.tsx +91 -0
- package/examples/canister-studio/src/routes/history.tsx +55 -0
- package/examples/canister-studio/src/routes/index.tsx +169 -0
- package/examples/canister-studio/src/routes/playground.tsx +286 -0
- package/examples/canister-studio/src/routes/safety.tsx +57 -0
- package/examples/canister-studio/src/routes/wallet.tsx +213 -0
- package/examples/canister-studio/src/safety/LabTransferForm.tsx +84 -0
- package/examples/canister-studio/src/safety/RequestLog.tsx +79 -0
- package/examples/canister-studio/src/safety/SafetyLab.test.tsx +89 -0
- package/examples/canister-studio/src/safety/SafetyLab.tsx +140 -0
- package/examples/canister-studio/src/safety/ScenarioCard.tsx +262 -0
- package/examples/canister-studio/src/safety/lab-run.ts +283 -0
- package/examples/canister-studio/src/safety/mock-ledger.test.ts +99 -0
- package/examples/canister-studio/src/safety/mock-ledger.ts +263 -0
- package/examples/canister-studio/src/safety/scenarios.test.ts +157 -0
- package/examples/canister-studio/src/safety/scenarios.ts +924 -0
- package/examples/canister-studio/src/styles.css +3453 -0
- package/examples/canister-studio/src/styles.test.ts +46 -0
- package/examples/canister-studio/src/values/CallPreview.test.tsx +86 -0
- package/examples/canister-studio/src/values/CallPreview.tsx +148 -0
- package/examples/canister-studio/src/values/ErrValueView.tsx +66 -0
- package/examples/canister-studio/src/values/Inspector.tsx +133 -0
- package/examples/canister-studio/src/values/ReplyView.test.tsx +341 -0
- package/examples/canister-studio/src/values/ReplyView.tsx +164 -0
- package/examples/canister-studio/src/values/candid-text.test.ts +516 -0
- package/examples/canister-studio/src/values/candid-text.ts +479 -0
- package/examples/canister-studio/src/values/hints.ts +58 -0
- package/examples/canister-studio/src/values/json.test.ts +66 -0
- package/examples/canister-studio/src/values/json.ts +31 -0
- package/examples/canister-studio/src/values/leaves.tsx +309 -0
- package/examples/canister-studio/src/values/schema.test.ts +123 -0
- package/examples/canister-studio/src/values/schema.ts +78 -0
- package/examples/canister-studio/src/values/test-router.tsx +21 -0
- package/examples/canister-studio/src/values/tree.tsx +670 -0
- package/examples/canister-studio/src/values/use-sha256.ts +29 -0
- package/examples/canister-studio/tsconfig.json +20 -0
- package/examples/canister-studio/vite.config.ts +50 -0
- package/examples/canister-studio/vitest.config.ts +20 -0
- package/examples/icrc-ledger/.stackblitzrc +4 -0
- package/examples/icrc-ledger/README.md +100 -0
- package/examples/icrc-ledger/ckbtc_minter.did +12 -0
- package/examples/icrc-ledger/icp_ledger.did +147 -0
- package/examples/icrc-ledger/icrc1.did +88 -0
- package/examples/icrc-ledger/index.html +13 -0
- package/examples/icrc-ledger/package.json +40 -0
- package/examples/icrc-ledger/scripts/smoke.ts +151 -0
- package/examples/icrc-ledger/src/App.tsx +71 -0
- package/examples/icrc-ledger/src/ErrorPanel.tsx +105 -0
- package/examples/icrc-ledger/src/MainnetTab.tsx +450 -0
- package/examples/icrc-ledger/src/SandboxTab.test.tsx +379 -0
- package/examples/icrc-ledger/src/SandboxTab.tsx +761 -0
- package/examples/icrc-ledger/src/blocks.test.ts +247 -0
- package/examples/icrc-ledger/src/blocks.ts +140 -0
- package/examples/icrc-ledger/src/canisters/ckbtc_minter.envelope.json +96 -0
- package/examples/icrc-ledger/src/canisters/ckbtc_minter.ts +16 -0
- package/examples/icrc-ledger/src/canisters/icp_ledger.envelope.json +690 -0
- package/examples/icrc-ledger/src/canisters/icp_ledger.ts +223 -0
- package/examples/icrc-ledger/src/canisters/icrc1.envelope.json +840 -0
- package/examples/icrc-ledger/src/canisters/icrc1.ts +78 -0
- package/examples/icrc-ledger/src/format.test.ts +41 -0
- package/examples/icrc-ledger/src/format.ts +68 -0
- package/examples/icrc-ledger/src/ledger.ts +15 -0
- package/examples/icrc-ledger/src/main.tsx +10 -0
- package/examples/icrc-ledger/src/minter.ts +16 -0
- package/examples/icrc-ledger/src/sandbox.test.ts +464 -0
- package/examples/icrc-ledger/src/sandbox.ts +281 -0
- package/examples/icrc-ledger/src/styles.css +391 -0
- package/examples/icrc-ledger/src/transfer-form.test.ts +62 -0
- package/examples/icrc-ledger/src/transfer-form.ts +97 -0
- package/examples/icrc-ledger/tsconfig.json +19 -0
- package/examples/icrc-ledger/vite.config.ts +44 -0
- package/examples/icrc-ledger/vitest.config.ts +12 -0
- package/examples/next-ssr/.stackblitzrc +4 -0
- package/examples/next-ssr/README.md +112 -0
- package/examples/next-ssr/icrc1.did +88 -0
- package/examples/next-ssr/next.config.ts +10 -0
- package/examples/next-ssr/package.json +41 -0
- package/examples/next-ssr/scripts/smoke.ts +169 -0
- package/examples/next-ssr/src/app/account/CertifiedBalances.tsx +58 -0
- package/examples/next-ssr/src/app/account/account.test.tsx +165 -0
- package/examples/next-ssr/src/app/account/page.tsx +82 -0
- package/examples/next-ssr/src/app/api/balance/[ledger]/[principal]/route.ts +10 -0
- package/examples/next-ssr/src/app/globals.css +278 -0
- package/examples/next-ssr/src/app/layout.tsx +34 -0
- package/examples/next-ssr/src/app/page.tsx +46 -0
- package/examples/next-ssr/src/app/providers.browser.test.tsx +127 -0
- package/examples/next-ssr/src/app/providers.test.tsx +73 -0
- package/examples/next-ssr/src/app/providers.tsx +60 -0
- package/examples/next-ssr/src/canisters/icrc1.envelope.json +840 -0
- package/examples/next-ssr/src/canisters/icrc1.ts +78 -0
- package/examples/next-ssr/src/components/BalanceTable.tsx +43 -0
- package/examples/next-ssr/src/components/LedgerCard.test.tsx +96 -0
- package/examples/next-ssr/src/components/LedgerCard.tsx +230 -0
- package/examples/next-ssr/src/components/LedgerError.tsx +32 -0
- package/examples/next-ssr/src/components/LedgerSections.tsx +22 -0
- package/examples/next-ssr/src/components/MyBalances.test.tsx +253 -0
- package/examples/next-ssr/src/components/MyBalances.tsx +129 -0
- package/examples/next-ssr/src/components/SessionBadge.tsx +18 -0
- package/examples/next-ssr/src/components/hydration.test.tsx +259 -0
- package/examples/next-ssr/src/components/server-data.ts +10 -0
- package/examples/next-ssr/src/format.ts +46 -0
- package/examples/next-ssr/src/ledgers.ts +38 -0
- package/examples/next-ssr/src/server/balance-route.test.ts +151 -0
- package/examples/next-ssr/src/server/balance-route.ts +157 -0
- package/examples/next-ssr/src/server/error-summary.test.ts +34 -0
- package/examples/next-ssr/src/server/error-summary.ts +37 -0
- package/examples/next-ssr/src/server/parse-owner.test.ts +39 -0
- package/examples/next-ssr/src/server/parse-owner.ts +34 -0
- package/examples/next-ssr/src/server/prefetch-ledgers.test.ts +137 -0
- package/examples/next-ssr/src/server/prefetch-ledgers.ts +70 -0
- package/examples/next-ssr/src/server/read-balances.test.ts +103 -0
- package/examples/next-ssr/src/server/read-balances.ts +78 -0
- package/examples/next-ssr/src/server/request-client.rsc.test.tsx +110 -0
- package/examples/next-ssr/src/server/request-client.ts +24 -0
- package/examples/next-ssr/src/testing/mock-ledgers.ts +115 -0
- package/examples/next-ssr/src/testing/react-server-dom-turbopack.d.ts +15 -0
- package/examples/next-ssr/src/testing/setup.ts +10 -0
- package/examples/next-ssr/tsconfig.json +31 -0
- package/examples/next-ssr/vitest.config.ts +59 -0
- package/examples/node-agent-tool/.stackblitzrc +4 -0
- package/examples/node-agent-tool/README.md +165 -0
- package/examples/node-agent-tool/icp.yaml +11 -0
- package/examples/node-agent-tool/icrc1.did +88 -0
- package/examples/node-agent-tool/package.json +32 -0
- package/examples/node-agent-tool/src/canisters/icrc1.envelope.json +840 -0
- package/examples/node-agent-tool/src/canisters/icrc1.ts +78 -0
- package/examples/node-agent-tool/src/cli.test.ts +64 -0
- package/examples/node-agent-tool/src/cli.ts +23 -0
- package/examples/node-agent-tool/src/command-line.ts +159 -0
- package/examples/node-agent-tool/src/commands/balance.ts +73 -0
- package/examples/node-agent-tool/src/commands/demo.test.ts +51 -0
- package/examples/node-agent-tool/src/commands/demo.ts +359 -0
- package/examples/node-agent-tool/src/commands/info.ts +69 -0
- package/examples/node-agent-tool/src/commands/reads.test.ts +133 -0
- package/examples/node-agent-tool/src/commands/resend.test.ts +363 -0
- package/examples/node-agent-tool/src/commands/transfer.test.ts +313 -0
- package/examples/node-agent-tool/src/commands/transfer.ts +415 -0
- package/examples/node-agent-tool/src/commands/watch.test.ts +159 -0
- package/examples/node-agent-tool/src/commands/watch.ts +135 -0
- package/examples/node-agent-tool/src/commands/whoami.ts +36 -0
- package/examples/node-agent-tool/src/context.ts +20 -0
- package/examples/node-agent-tool/src/dispatch.ts +70 -0
- package/examples/node-agent-tool/src/failure.test.ts +59 -0
- package/examples/node-agent-tool/src/failure.ts +176 -0
- package/examples/node-agent-tool/src/identity.test.ts +242 -0
- package/examples/node-agent-tool/src/identity.ts +228 -0
- package/examples/node-agent-tool/src/input.ts +110 -0
- package/examples/node-agent-tool/src/ledgers.test.ts +74 -0
- package/examples/node-agent-tool/src/ledgers.ts +62 -0
- package/examples/node-agent-tool/src/mock-ledger.ts +211 -0
- package/examples/node-agent-tool/src/network.test.ts +128 -0
- package/examples/node-agent-tool/src/network.ts +101 -0
- package/examples/node-agent-tool/src/output.test.ts +208 -0
- package/examples/node-agent-tool/src/output.ts +163 -0
- package/examples/node-agent-tool/src/run.ts +73 -0
- package/examples/node-agent-tool/src/test-kit.ts +167 -0
- package/examples/node-agent-tool/tsconfig.json +18 -0
- package/examples/node-agent-tool/vitest.config.ts +8 -0
- package/examples/vite-wallet/.stackblitzrc +4 -0
- package/examples/vite-wallet/Cargo.lock +646 -0
- package/examples/vite-wallet/Cargo.toml +5 -0
- package/examples/vite-wallet/README.md +107 -0
- package/examples/vite-wallet/backend/Cargo.toml +12 -0
- package/examples/vite-wallet/backend/backend.did +37 -0
- package/examples/vite-wallet/backend/src/lib.rs +133 -0
- package/examples/vite-wallet/icp.yaml +44 -0
- package/examples/vite-wallet/index.html +13 -0
- package/examples/vite-wallet/ledger.did +54 -0
- package/examples/vite-wallet/package.json +46 -0
- package/examples/vite-wallet/public/.ic-assets.json5 +26 -0
- package/examples/vite-wallet/scripts/faucet-args.test.ts +30 -0
- package/examples/vite-wallet/scripts/faucet-args.ts +41 -0
- package/examples/vite-wallet/scripts/faucet.ts +41 -0
- package/examples/vite-wallet/src/AddressBook.test.tsx +191 -0
- package/examples/vite-wallet/src/AddressBook.tsx +147 -0
- package/examples/vite-wallet/src/App.tsx +53 -0
- package/examples/vite-wallet/src/Balance.test.tsx +57 -0
- package/examples/vite-wallet/src/Balance.tsx +58 -0
- package/examples/vite-wallet/src/EnvironmentPanel.test.tsx +83 -0
- package/examples/vite-wallet/src/EnvironmentPanel.tsx +103 -0
- package/examples/vite-wallet/src/ErrorNote.tsx +70 -0
- package/examples/vite-wallet/src/Profile.test.tsx +145 -0
- package/examples/vite-wallet/src/Profile.tsx +92 -0
- package/examples/vite-wallet/src/SendIcp.test.tsx +231 -0
- package/examples/vite-wallet/src/SendIcp.tsx +244 -0
- package/examples/vite-wallet/src/SendToContact.test.tsx +113 -0
- package/examples/vite-wallet/src/SendToContact.tsx +102 -0
- package/examples/vite-wallet/src/SignIn.test.tsx +106 -0
- package/examples/vite-wallet/src/SignIn.tsx +120 -0
- package/examples/vite-wallet/src/Wallet.test.tsx +89 -0
- package/examples/vite-wallet/src/account-switch.test.tsx +108 -0
- package/examples/vite-wallet/src/auth/dev-accounts.test.ts +113 -0
- package/examples/vite-wallet/src/auth/dev-accounts.ts +216 -0
- package/examples/vite-wallet/src/auth/local-page.test.ts +28 -0
- package/examples/vite-wallet/src/auth/local-page.ts +20 -0
- package/examples/vite-wallet/src/auth/wallet-auth.test.ts +159 -0
- package/examples/vite-wallet/src/auth/wallet-auth.ts +131 -0
- package/examples/vite-wallet/src/canisters/backend.envelope.json +275 -0
- package/examples/vite-wallet/src/canisters/backend.ts +58 -0
- package/examples/vite-wallet/src/canisters/ledger.envelope.json +451 -0
- package/examples/vite-wallet/src/canisters/ledger.ts +56 -0
- package/examples/vite-wallet/src/client.ts +45 -0
- package/examples/vite-wallet/src/main.tsx +18 -0
- package/examples/vite-wallet/src/optimistic-contacts.ts +113 -0
- package/examples/vite-wallet/src/refusal.ts +17 -0
- package/examples/vite-wallet/src/styles.css +282 -0
- package/examples/vite-wallet/src/test/test-wallet.tsx +322 -0
- package/examples/vite-wallet/src/token.ts +50 -0
- package/examples/vite-wallet/src/transfer.test.ts +178 -0
- package/examples/vite-wallet/src/transfer.ts +110 -0
- package/examples/vite-wallet/src/use-canisters.ts +49 -0
- package/examples/vite-wallet/tsconfig.json +19 -0
- package/examples/vite-wallet/vite.config.ts +47 -0
- package/examples/vite-wallet/vitest.config.ts +17 -0
- package/package.json +9 -4
- package/packages/core/.size-limit.js +47 -0
- package/packages/core/LICENSE +21 -0
- package/packages/core/README.md +84 -0
- package/packages/core/llms.txt +610 -0
- package/packages/core/package.json +89 -0
- package/packages/core/src/call.ts +961 -0
- package/packages/core/src/canister.ts +259 -0
- package/packages/core/src/client.ts +2061 -0
- package/packages/core/src/errors.ts +1079 -0
- package/packages/core/src/hydration.ts +239 -0
- package/packages/core/src/index.ts +23 -0
- package/packages/core/src/keys.ts +108 -0
- package/packages/core/src/management.ts +138 -0
- package/packages/core/src/network.ts +1020 -0
- package/packages/core/src/options.ts +1000 -0
- package/packages/core/src/runtime.ts +50 -0
- package/packages/core/src/testing/fake-replica.ts +1166 -0
- package/packages/core/src/testing/index.ts +16 -0
- package/packages/core/src/testing/test-auth.ts +340 -0
- package/packages/core/src/testing/test-client.ts +559 -0
- package/packages/core/src/types.ts +541 -0
- package/packages/core/src/units.ts +299 -0
- package/packages/core/src/version.ts +4 -0
- package/packages/core/tests/canister-helpers.ts +187 -0
- package/packages/core/tests/canister-id.test.ts +158 -0
- package/packages/core/tests/canister-unwrap.test.ts +94 -0
- package/packages/core/tests/canister.test-d.ts +406 -0
- package/packages/core/tests/canister.test.ts +1921 -0
- package/packages/core/tests/client-account-change.test.ts +131 -0
- package/packages/core/tests/client-agents.test.ts +402 -0
- package/packages/core/tests/client-auth-network.test.ts +719 -0
- package/packages/core/tests/client-disposed.test.ts +403 -0
- package/packages/core/tests/client-helpers.ts +136 -0
- package/packages/core/tests/client-hydration.test.ts +166 -0
- package/packages/core/tests/client-pinned.test.ts +626 -0
- package/packages/core/tests/client-session.test.ts +348 -0
- package/packages/core/tests/client.test-d.ts +181 -0
- package/packages/core/tests/client.test.ts +1162 -0
- package/packages/core/tests/encode.test.ts +171 -0
- package/packages/core/tests/env-canister-id.test.ts +666 -0
- package/packages/core/tests/env-cookie-copies.test.ts +182 -0
- package/packages/core/tests/error-guard.test.ts +222 -0
- package/packages/core/tests/errors.test-d.ts +280 -0
- package/packages/core/tests/errors.test.ts +1359 -0
- package/packages/core/tests/fixtures/README.md +28 -0
- package/packages/core/tests/fixtures/accounts.did +21 -0
- package/packages/core/tests/fixtures/accounts.envelope.json +367 -0
- package/packages/core/tests/fixtures/accounts.ts +26 -0
- package/packages/core/tests/fixtures/archive.did +31 -0
- package/packages/core/tests/fixtures/archive.envelope.json +259 -0
- package/packages/core/tests/fixtures/archive.ts +55 -0
- package/packages/core/tests/fixtures/icrc1.did +88 -0
- package/packages/core/tests/fixtures/icrc1.envelope.json +840 -0
- package/packages/core/tests/fixtures/icrc1.ts +78 -0
- package/packages/core/tests/fixtures/management.did +724 -0
- package/packages/core/tests/fixtures/management.envelope.json +4455 -0
- package/packages/core/tests/fixtures/management.ts +486 -0
- package/packages/core/tests/fixtures/shapes.did +33 -0
- package/packages/core/tests/fixtures/shapes.envelope.json +440 -0
- package/packages/core/tests/fixtures/shapes.ts +56 -0
- package/packages/core/tests/fixtures/skippable.did +15 -0
- package/packages/core/tests/fixtures/skippable.envelope.json +182 -0
- package/packages/core/tests/fixtures/skippable.ts +33 -0
- package/packages/core/tests/func.test.ts +154 -0
- package/packages/core/tests/identity-attributes.test.ts +214 -0
- package/packages/core/tests/keys.test.ts +289 -0
- package/packages/core/tests/management.test.ts +206 -0
- package/packages/core/tests/network-helpers.ts +121 -0
- package/packages/core/tests/network.test-d.ts +78 -0
- package/packages/core/tests/network.test.ts +1051 -0
- package/packages/core/tests/options.test.ts +1594 -0
- package/packages/core/tests/redirect-sign-in.test.ts +393 -0
- package/packages/core/tests/resend.test-d.ts +81 -0
- package/packages/core/tests/resend.test.ts +723 -0
- package/packages/core/tests/skip.test-d.ts +125 -0
- package/packages/core/tests/testing-entry.test.ts +73 -0
- package/packages/core/tests/testing-fake-replica.test.ts +1242 -0
- package/packages/core/tests/testing-test-auth.test-d.ts +84 -0
- package/packages/core/tests/testing-test-auth.test.ts +483 -0
- package/packages/core/tests/testing-test-client.test-d.ts +184 -0
- package/packages/core/tests/testing-test-client.test.ts +1272 -0
- package/packages/core/tests/time-realm.test.ts +63 -0
- package/packages/core/tests/time.test.ts +238 -0
- package/packages/core/tests/traps/README.md +39 -0
- package/packages/core/tests/traps/anonymous-write.test.ts +145 -0
- package/packages/core/tests/traps/classification-table.test.ts +256 -0
- package/packages/core/tests/traps/err-arm.test.ts +144 -0
- package/packages/core/tests/traps/fetch-spy.ts +39 -0
- package/packages/core/tests/traps/invalidation-after-write.test.ts +178 -0
- package/packages/core/tests/traps/ledger.ts +248 -0
- package/packages/core/tests/traps/root-key-off-local.test.ts +130 -0
- package/packages/core/tests/traps/stale-data-across-users.test.ts +173 -0
- package/packages/core/tests/traps/unresolved-name.test.ts +155 -0
- package/packages/core/tests/traps/update-as-query.test.ts +244 -0
- package/packages/core/tests/traps/update-not-resent.test.ts +126 -0
- package/packages/core/tests/traps/update-resent-when-never-delivered.test.ts +159 -0
- package/packages/core/tests/traps.test-d.ts +476 -0
- package/packages/core/tests/units.test-d.ts +118 -0
- package/packages/core/tests/units.test.ts +634 -0
- package/packages/core/tsconfig.json +19 -0
- package/packages/core/tsconfig.typecheck.json +16 -0
- package/packages/core/vitest.config.ts +13 -0
- package/packages/mcp/LICENSE +21 -0
- package/packages/mcp/README.md +189 -0
- package/packages/mcp/package.json +62 -0
- package/packages/mcp/src/candid/candid-text.test.ts +455 -0
- package/packages/mcp/src/candid/candid-text.ts +501 -0
- package/packages/mcp/src/candid/contract.test.ts +398 -0
- package/packages/mcp/src/candid/formats.ts +155 -0
- package/packages/mcp/src/candid/hex.ts +29 -0
- package/packages/mcp/src/candid/json-schema.test.ts +758 -0
- package/packages/mcp/src/candid/json-schema.ts +454 -0
- package/packages/mcp/src/candid/json.test.ts +1207 -0
- package/packages/mcp/src/candid/json.ts +1053 -0
- package/packages/mcp/src/candid/schema-walk.ts +189 -0
- package/packages/mcp/src/candid/type-text.test.ts +179 -0
- package/packages/mcp/src/candid/type-text.ts +238 -0
- package/packages/mcp/src/cli.ts +134 -0
- package/packages/mcp/src/identity.ts +188 -0
- package/packages/mcp/src/interface/cache.test.ts +48 -0
- package/packages/mcp/src/interface/cache.ts +68 -0
- package/packages/mcp/src/interface/cli-cache.test.ts +150 -0
- package/packages/mcp/src/interface/cli-cache.ts +122 -0
- package/packages/mcp/src/interface/compile-worker.test.ts +246 -0
- package/packages/mcp/src/interface/compile-worker.ts +290 -0
- package/packages/mcp/src/interface/compile.test.ts +155 -0
- package/packages/mcp/src/interface/compile.ts +281 -0
- package/packages/mcp/src/interface/docs.test.ts +105 -0
- package/packages/mcp/src/interface/docs.ts +86 -0
- package/packages/mcp/src/interface/fixtures/icrc1.did +88 -0
- package/packages/mcp/src/interface/hex.ts +8 -0
- package/packages/mcp/src/interface/ids.test.ts +71 -0
- package/packages/mcp/src/interface/ids.ts +87 -0
- package/packages/mcp/src/interface/loader.test.ts +804 -0
- package/packages/mcp/src/interface/loader.ts +519 -0
- package/packages/mcp/src/interface/read-candid.test.ts +300 -0
- package/packages/mcp/src/interface/read-candid.ts +259 -0
- package/packages/mcp/src/interface/types.ts +113 -0
- package/packages/mcp/src/network.ts +122 -0
- package/packages/mcp/src/options.ts +149 -0
- package/packages/mcp/src/server/approval.ts +159 -0
- package/packages/mcp/src/server/naming.ts +43 -0
- package/packages/mcp/src/server/results.ts +102 -0
- package/packages/mcp/src/server/server.ts +797 -0
- package/packages/mcp/src/server/tools.ts +116 -0
- package/packages/mcp/src/usage.ts +40 -0
- package/packages/mcp/tests/approval.test.ts +60 -0
- package/packages/mcp/tests/identity.test.ts +234 -0
- package/packages/mcp/tests/naming.test.ts +62 -0
- package/packages/mcp/tests/options.test.ts +140 -0
- package/packages/mcp/tests/server.test.ts +997 -0
- package/packages/mcp/tsconfig.json +19 -0
- package/packages/mcp/tsconfig.typecheck.json +10 -0
- package/packages/mcp/vitest.config.ts +10 -0
- package/packages/react/LICENSE +21 -0
- package/packages/react/README.md +328 -0
- package/packages/react/llms.txt +95 -0
- package/packages/react/package.json +95 -0
- package/packages/react/src/index.tsx +678 -0
- package/packages/react/tests/abandoned-render.test.tsx +816 -0
- package/packages/react/tests/auth.test.tsx +315 -0
- package/packages/react/tests/caller-keys.test.tsx +2334 -0
- package/packages/react/tests/client-time.test.ts +40 -0
- package/packages/react/tests/entry.test.ts +52 -0
- package/packages/react/tests/helpers.tsx +291 -0
- package/packages/react/tests/hydration.test.tsx +262 -0
- package/packages/react/tests/ownership.test.tsx +260 -0
- package/packages/react/tests/provider.server.test.tsx +169 -0
- package/packages/react/tests/query-client.test.tsx +107 -0
- package/packages/react/tests/strict-mode.test.tsx +288 -0
- package/packages/react/tests/suspense.test-d.tsx +357 -0
- package/packages/react/tests/two-query-cores.test.tsx +135 -0
- package/packages/react/tests/types.test-d.tsx +63 -0
- package/packages/react/tsconfig.json +23 -0
- package/packages/react/tsconfig.test.json +8 -0
- package/packages/react/tsconfig.typecheck.json +16 -0
- package/packages/react/vitest.config.ts +39 -0
- package/packages/vite-plugin/LICENSE +21 -0
- package/packages/vite-plugin/README.md +532 -0
- package/packages/vite-plugin/package.json +76 -0
- package/packages/vite-plugin/src/describe-value.test.ts +43 -0
- package/packages/vite-plugin/src/describe-value.ts +167 -0
- package/packages/vite-plugin/src/dev-environment.test.ts +747 -0
- package/packages/vite-plugin/src/dev-environment.ts +510 -0
- package/packages/vite-plugin/src/env.ts +431 -0
- package/packages/vite-plugin/src/fetch.ts +348 -0
- package/packages/vite-plugin/src/file-identity.ts +106 -0
- package/packages/vite-plugin/src/generate.ts +691 -0
- package/packages/vite-plugin/src/index.test.ts +1243 -0
- package/packages/vite-plugin/src/index.ts +2565 -0
- package/packages/vite-plugin/src/interface.ts +339 -0
- package/packages/vite-plugin/src/local-host.ts +34 -0
- package/packages/vite-plugin/src/options.ts +641 -0
- package/packages/vite-plugin/src/principal-text.test.ts +87 -0
- package/packages/vite-plugin/src/principal-text.ts +98 -0
- package/packages/vite-plugin/tests/close.test.ts +141 -0
- package/packages/vite-plugin/tests/environment-mode.test.ts +136 -0
- package/packages/vite-plugin/tests/fake-ic.ts +214 -0
- package/packages/vite-plugin/tests/fetch-sdk.test.ts +27 -0
- package/packages/vite-plugin/tests/fetch.test.ts +1196 -0
- package/packages/vite-plugin/tests/fixtures/fake-cli/bin.js +90 -0
- package/packages/vite-plugin/tests/fixtures/fake-cli/package.json +8 -0
- package/packages/vite-plugin/tests/fixtures/icrc1.did +88 -0
- package/packages/vite-plugin/tests/generation.test.ts +1036 -0
- package/packages/vite-plugin/tests/icp-command.test.ts +194 -0
- package/packages/vite-plugin/tests/icp-timeout.test.ts +83 -0
- package/packages/vite-plugin/tests/no-process-exit.test.ts +83 -0
- package/packages/vite-plugin/tests/on-generated.test.ts +323 -0
- package/packages/vite-plugin/tests/output-collision.test.ts +329 -0
- package/packages/vite-plugin/tests/package.test.ts +62 -0
- package/packages/vite-plugin/tests/project.test.ts +2993 -0
- package/packages/vite-plugin/tests/support.ts +339 -0
- package/packages/vite-plugin/tests/timeout.test.ts +107 -0
- package/packages/vite-plugin/tsconfig.json +23 -0
- package/packages/vite-plugin/tsconfig.typecheck.json +16 -0
- package/packages/vite-plugin/vitest.config.ts +9 -0
- package/pnpm-workspace.yaml +23 -0
- package/scripts/ai-context-files.js +72 -0
- package/scripts/assemble-docs-site.mjs +485 -0
- package/scripts/assemble-docs-site.test.mjs +568 -0
- package/scripts/build-examples.js +58 -0
- package/scripts/check-ai-context.js +963 -0
- package/scripts/check-ai-context.test.mjs +811 -0
- package/scripts/check-exports.mjs +767 -0
- package/scripts/check-exports.test.mjs +1136 -0
- package/scripts/check-snippets/ambient.d.ts +428 -0
- package/scripts/check-snippets/app/generated/backend.did +7 -0
- package/scripts/check-snippets/app/generated/backend.ts +15 -0
- package/scripts/check-snippets/app/generated/icrc1.did +88 -0
- package/scripts/check-snippets/app/generated/icrc1.ts +78 -0
- package/scripts/check-snippets/app/globals.ts +26 -0
- package/scripts/check-snippets/docs/guides/App.tsx +21 -0
- package/scripts/check-snippets/docs/guides/app/providers.tsx +23 -0
- package/scripts/check-snippets/docs/guides/auth-client.ts +47 -0
- package/scripts/check-snippets/docs/guides/canisters/backend.ts +3 -0
- package/scripts/check-snippets/docs/guides/canisters/icrc1.ts +3 -0
- package/scripts/check-snippets/docs/guides/components/LedgerCard.tsx +26 -0
- package/scripts/check-snippets/docs/guides/components/shell.tsx +11 -0
- package/scripts/check-snippets/docs/guides/components/sign-in-page.tsx +29 -0
- package/scripts/check-snippets/docs/guides/components/slow-section.tsx +5 -0
- package/scripts/check-snippets/docs/guides/generated/accounts.ts +95 -0
- package/scripts/check-snippets/docs/guides/generated/ckbtc_minter.ts +40 -0
- package/scripts/check-snippets/docs/guides/generated/icp_ledger.ts +267 -0
- package/scripts/check-snippets/docs/guides/ledger.ts +10 -0
- package/scripts/check-snippets/docs/guides/server/request-client.ts +8 -0
- package/scripts/check-snippets.mjs +993 -0
- package/scripts/check-snippets.test.mjs +338 -0
- package/scripts/check-stack.mjs +501 -0
- package/scripts/check-stack.test.mjs +800 -0
- package/scripts/clean-node-modules.js +67 -0
- package/scripts/example-projects.js +58 -0
- package/scripts/export-budget.mjs +145 -0
- package/scripts/faults.json +6749 -0
- package/scripts/lib/fault-workspace.mjs +210 -0
- package/scripts/lib/io.mjs +29 -0
- package/scripts/lib/io.test.mjs +38 -0
- package/scripts/lib/markdown-fences.mjs +56 -0
- package/scripts/lib/markdown-fences.test.mjs +84 -0
- package/scripts/lib/package-exports.mjs +54 -0
- package/scripts/lib/package-exports.test.mjs +66 -0
- package/scripts/lib/release-pr-steps.mjs +47 -0
- package/scripts/release-tag.mjs +95 -0
- package/scripts/release-tag.test.mjs +278 -0
- package/scripts/release-workflow.test.mjs +98 -0
- package/scripts/release.js +167 -0
- package/scripts/release.test.mjs +347 -0
- package/scripts/removed-v3-names.js +125 -0
- package/scripts/size-app/.size-limit.js +90 -0
- package/scripts/size-app/core.js +3 -0
- package/scripts/size-app/package.json +24 -0
- package/scripts/size-app/react.js +4 -0
- package/scripts/size-app.test.mjs +73 -0
- package/scripts/sync-ai-context-versions.js +102 -0
- package/scripts/sync-example-versions.js +151 -0
- package/scripts/test-examples-published.mjs +1585 -0
- package/scripts/test-examples-published.test.mjs +1586 -0
- package/scripts/traps/any-method-vars-not-a-list.json +5 -0
- package/scripts/traps/canister-method-unknown.json +5 -0
- package/scripts/traps/client-says-who-calls-once.json +5 -0
- package/scripts/traps/client-says-who-calls.json +5 -0
- package/scripts/traps/err-only-on-canister-err.json +14 -0
- package/scripts/traps/format-units-refuses-missing-value.json +5 -0
- package/scripts/traps/format-units-refuses-number.json +5 -0
- package/scripts/traps/format-units-unknown-option.json +5 -0
- package/scripts/traps/guard-err-is-the-methods.json +5 -0
- package/scripts/traps/guard-false-keeps-every-kind.json +5 -0
- package/scripts/traps/internals-not-exported.json +9 -0
- package/scripts/traps/network-refuses-unknown-name.json +5 -0
- package/scripts/traps/never-err-has-no-type.json +5 -0
- package/scripts/traps/options-method-unknown.json +5 -0
- package/scripts/traps/options-vars-wrong-type.json +5 -0
- package/scripts/traps/parse-units-refuses-number.json +5 -0
- package/scripts/traps/parse-units-unknown-option.json +5 -0
- package/scripts/traps/principal-is-not-plain-text.json +14 -0
- package/scripts/traps/query-options-refuses-oneway.json +5 -0
- package/scripts/traps/query-options-refuses-update.json +5 -0
- package/scripts/traps/refuse-next-unknown-option.json +5 -0
- package/scripts/traps/resend-key-reads-the-methods-arg.json +5 -0
- package/scripts/traps/resend-states-its-dedupe-key.json +5 -0
- package/scripts/traps/suspense-refuses-argument-skip.json +5 -0
- package/scripts/traps/suspense-refuses-skippable-read.json +5 -0
- package/scripts/traps/suspense-refuses-vars-skip-fits.json +5 -0
- package/scripts/traps/test-handler-method-unknown.json +5 -0
- package/scripts/traps/test-handler-nat-is-bigint.json +5 -0
- package/scripts/traps/test-handler-takes-the-domain-value.json +5 -0
- package/scripts/traps/vars-of-two-arguments-are-the-tuple.json +5 -0
- package/scripts/typecheck-examples.js +122 -0
- package/scripts/verify-faults.mjs +375 -0
- package/scripts/verify-faults.test.mjs +402 -0
- package/scripts/verify-package-artifacts.mjs +590 -0
- package/scripts/verify-peer-floors.js +399 -0
- package/scripts/verify-peer-floors.test.mjs +422 -0
- package/scripts/verify-test-fails-rules.mjs +104 -0
- package/scripts/verify-test-fails-rules.test.mjs +215 -0
- package/scripts/verify-test-fails.js +371 -0
- package/scripts/verify-traps.mjs +505 -0
- package/scripts/verify-traps.test.mjs +521 -0
- package/scripts/verify-typescript-floor.js +239 -0
- package/skill-packages/ic-reactor/SKILL.md +103 -0
- package/stack.json +8 -0
- package/tsconfig.base.json +23 -0
- package/tsconfig.json +8 -0
|
@@ -0,0 +1,2334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query keys follow the caller React renders with (#813).
|
|
3
|
+
*
|
|
4
|
+
* A server render has no session: it prefetches and renders as the anonymous
|
|
5
|
+
* principal, and `useAuth()` honours that with an anonymous server snapshot.
|
|
6
|
+
* The keys `useClient()` builds honour it too: it follows the client's caller
|
|
7
|
+
* with `useSyncExternalStore` and the same anonymous server snapshot. An
|
|
8
|
+
* `AuthClient` reads a stored session synchronously, so for a visitor who
|
|
9
|
+
* signed in on an earlier visit the client's live caller is already the user
|
|
10
|
+
* while the page hydrates; keys built from it would miss the data the server
|
|
11
|
+
* dehydrated, render other markup than the server's HTML, and send every
|
|
12
|
+
* prefetched read again.
|
|
13
|
+
*
|
|
14
|
+
* React moves a hydrated component on to the live caller itself, with the
|
|
15
|
+
* synchronous update `useSyncExternalStore` makes after hydrating when the
|
|
16
|
+
* snapshot differs from the server's. Two consequences are accepted and
|
|
17
|
+
* pinned here: a Suspense boundary still dehydrated below a component that
|
|
18
|
+
* moves is rendered on the client (L1), and a read the hydrating render built
|
|
19
|
+
* may run once and be cancelled before anything is sent (L2).
|
|
20
|
+
*
|
|
21
|
+
* Every client here is a `createTestClient()` over an in-memory replica whose
|
|
22
|
+
* one canister answers with the caller it saw, so a test reads from its log
|
|
23
|
+
* who sent what, and from the page whose answer it shows.
|
|
24
|
+
*/
|
|
25
|
+
import { c } from "@meantjs/schema"
|
|
26
|
+
import {
|
|
27
|
+
createClient,
|
|
28
|
+
isReactorError,
|
|
29
|
+
type Canister,
|
|
30
|
+
type Client,
|
|
31
|
+
} from "@ic-reactor/core"
|
|
32
|
+
import { createTestClient } from "@ic-reactor/core/testing"
|
|
33
|
+
import {
|
|
34
|
+
HydrationBoundary,
|
|
35
|
+
dehydrate,
|
|
36
|
+
useMutation,
|
|
37
|
+
useQuery,
|
|
38
|
+
useSuspenseQuery,
|
|
39
|
+
type DehydratedState,
|
|
40
|
+
} from "@tanstack/react-query"
|
|
41
|
+
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
|
|
42
|
+
import {
|
|
43
|
+
Component,
|
|
44
|
+
StrictMode,
|
|
45
|
+
Suspense,
|
|
46
|
+
lazy,
|
|
47
|
+
startTransition,
|
|
48
|
+
useEffect,
|
|
49
|
+
useLayoutEffect,
|
|
50
|
+
useMemo,
|
|
51
|
+
useRef,
|
|
52
|
+
useState,
|
|
53
|
+
version,
|
|
54
|
+
type ComponentType,
|
|
55
|
+
type ReactElement,
|
|
56
|
+
type ReactNode,
|
|
57
|
+
} from "react"
|
|
58
|
+
import { hydrateRoot, type Root } from "react-dom/client"
|
|
59
|
+
import { renderToString } from "react-dom/server"
|
|
60
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
|
61
|
+
import { ReactorProvider, useAuth, useClient } from "../src/index.js"
|
|
62
|
+
import { clientOf, macrotask, testAuth } from "./helpers.js"
|
|
63
|
+
|
|
64
|
+
const ANONYMOUS = "2vxsx-fae"
|
|
65
|
+
const CANISTER = "rdmx6-jaaaa-aaaaa-aaadq-cai"
|
|
66
|
+
const REACT_18 = Number(version.split(".")[0]) < 19
|
|
67
|
+
/** What an empty slot of React Compiler's cache holds (`react-compiler-runtime`'s `$empty`). */
|
|
68
|
+
const EMPTY = Symbol.for("react.memo_cache_sentinel")
|
|
69
|
+
|
|
70
|
+
/** One query that answers with who called it. */
|
|
71
|
+
const WHO = c.service({
|
|
72
|
+
whoami: c.func([], [c.text], "query"),
|
|
73
|
+
stamp: c.func([], [c.text], "update"),
|
|
74
|
+
})
|
|
75
|
+
type Who = { whoami: () => Promise<string>; stamp: () => Promise<string> }
|
|
76
|
+
/** The options of a `whoami` read, as a client builds them. */
|
|
77
|
+
type WhoamiOptions = ReturnType<typeof whoamiOptions>
|
|
78
|
+
const whoamiOptions = (client: Client, who: Canister<Who>) =>
|
|
79
|
+
client.queryOptions(who, "whoami")
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A test client and its replica. Signed in as the identity of `seed` unless
|
|
83
|
+
* `signedIn` is `false`, as a server's client and a first visit are.
|
|
84
|
+
*/
|
|
85
|
+
function replica(options: { seed?: number; signedIn?: boolean } = {}) {
|
|
86
|
+
const test = createTestClient({
|
|
87
|
+
identity: options.seed ?? 7,
|
|
88
|
+
signedIn: options.signedIn ?? true,
|
|
89
|
+
})
|
|
90
|
+
test.mock<Who>(WHO, CANISTER, {
|
|
91
|
+
whoami: ({ caller }) => `read by ${caller}`,
|
|
92
|
+
stamp: ({ caller }) => `written by ${caller}`,
|
|
93
|
+
})
|
|
94
|
+
tests.push(test)
|
|
95
|
+
return test
|
|
96
|
+
}
|
|
97
|
+
type Replica = ReturnType<typeof replica>
|
|
98
|
+
|
|
99
|
+
/** The callers of every `whoami` the replica answered, in order. */
|
|
100
|
+
const whoamiCallers = (test: Replica): (string | undefined)[] =>
|
|
101
|
+
test.requests
|
|
102
|
+
.filter(({ methodName }) => methodName === "whoami")
|
|
103
|
+
.map(({ caller }) => caller)
|
|
104
|
+
|
|
105
|
+
const tests: ReturnType<typeof createTestClient>[] = []
|
|
106
|
+
/** Clients a test made with `createClient`, disposed after it. */
|
|
107
|
+
const made: Client[] = []
|
|
108
|
+
let root: Root | undefined
|
|
109
|
+
let container: HTMLElement
|
|
110
|
+
|
|
111
|
+
beforeEach(() => {
|
|
112
|
+
container = document.createElement("div")
|
|
113
|
+
document.body.appendChild(container)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
afterEach(() => {
|
|
117
|
+
act(() => root?.unmount())
|
|
118
|
+
root = undefined
|
|
119
|
+
container.remove()
|
|
120
|
+
for (const test of tests.splice(0)) test.client.dispose()
|
|
121
|
+
for (const client of made.splice(0)) client.dispose()
|
|
122
|
+
vi.restoreAllMocks()
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
/** A component that reads with `useClient()` alone: no `useAuth()`. */
|
|
126
|
+
function Reader() {
|
|
127
|
+
const client = useClient()
|
|
128
|
+
const who = client.canister<Who>(WHO, { id: CANISTER })
|
|
129
|
+
const read = useQuery(client.queryOptions(who, "whoami"))
|
|
130
|
+
return <main>{read.isError ? "ERROR" : (read.data ?? "loading")}</main>
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The root's own `useAuth()`, which moves on to the session after hydrating. */
|
|
134
|
+
function Header() {
|
|
135
|
+
const { status } = useAuth()
|
|
136
|
+
return <header>{status}</header>
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The page: a header at the root, and the reader below a Suspense boundary. */
|
|
140
|
+
function page(
|
|
141
|
+
client: Client,
|
|
142
|
+
state: DehydratedState,
|
|
143
|
+
Late: ComponentType
|
|
144
|
+
): ReactElement {
|
|
145
|
+
return (
|
|
146
|
+
<ReactorProvider client={() => client}>
|
|
147
|
+
<Header />
|
|
148
|
+
<HydrationBoundary state={state}>
|
|
149
|
+
<Suspense fallback={<p>loading</p>}>
|
|
150
|
+
<Late />
|
|
151
|
+
</Suspense>
|
|
152
|
+
</HydrationBoundary>
|
|
153
|
+
</ReactorProvider>
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* What a server sends: it prefetches as nobody, dehydrates, and renders the
|
|
159
|
+
* page to HTML with that client. The state crosses as JSON, as it does in an
|
|
160
|
+
* app.
|
|
161
|
+
*/
|
|
162
|
+
async function serverRender() {
|
|
163
|
+
const server = replica({ signedIn: false })
|
|
164
|
+
const who = server.client.canister<Who>(WHO, { id: CANISTER })
|
|
165
|
+
await server.client.queryClient.prefetchQuery(
|
|
166
|
+
server.client.queryOptions(who, "whoami")
|
|
167
|
+
)
|
|
168
|
+
const json = JSON.stringify(dehydrate(server.client.queryClient))
|
|
169
|
+
const html = renderToString(page(server.client, JSON.parse(json), Reader))
|
|
170
|
+
return { server, json, html }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Hydrates `container` with `element`, returning what React reported: the
|
|
175
|
+
* messages of its recoverable errors, the errors themselves, and its console.
|
|
176
|
+
*/
|
|
177
|
+
async function hydrate(element: ReactElement) {
|
|
178
|
+
const recoverable: string[] = []
|
|
179
|
+
const reported: unknown[] = []
|
|
180
|
+
const logged = vi.spyOn(console, "error").mockImplementation(() => {})
|
|
181
|
+
await act(async () => {
|
|
182
|
+
root = hydrateRoot(container, element, {
|
|
183
|
+
onRecoverableError: (error) => {
|
|
184
|
+
reported.push(error)
|
|
185
|
+
recoverable.push(String((error as Error).message))
|
|
186
|
+
},
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
await act(macrotask)
|
|
190
|
+
return { recoverable, reported, logged }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Every `innerHTML` of `container` that a commit left, in order. */
|
|
194
|
+
function watchCommits() {
|
|
195
|
+
const commits: string[] = []
|
|
196
|
+
const watcher = new MutationObserver(() => commits.push(container.innerHTML))
|
|
197
|
+
watcher.observe(container, {
|
|
198
|
+
subtree: true,
|
|
199
|
+
childList: true,
|
|
200
|
+
characterData: true,
|
|
201
|
+
})
|
|
202
|
+
return { commits, stop: () => watcher.disconnect() }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Every error a query of `client` went into, in order. */
|
|
206
|
+
function watchErrors(client: Client) {
|
|
207
|
+
const errors: unknown[] = []
|
|
208
|
+
const stop = client.queryClient.getQueryCache().subscribe((event) => {
|
|
209
|
+
if (event.type === "updated" && event.action.type === "error") {
|
|
210
|
+
errors.push(event.action.error)
|
|
211
|
+
}
|
|
212
|
+
})
|
|
213
|
+
return { errors, stop }
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** A lazy component whose chunk arrives when the test calls `deliver`. */
|
|
217
|
+
function lateChunk(Content: ComponentType) {
|
|
218
|
+
let deliver!: () => void
|
|
219
|
+
const Late = lazy(
|
|
220
|
+
() =>
|
|
221
|
+
new Promise<{ default: ComponentType }>((resolve) => {
|
|
222
|
+
deliver = () => resolve({ default: Content })
|
|
223
|
+
})
|
|
224
|
+
)
|
|
225
|
+
return { Late, deliver: () => deliver() }
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The server's HTML of a boundary rewritten the way a stream sends it before
|
|
230
|
+
* its content: pending, with the fallback in its place. React waits for the
|
|
231
|
+
* stream while the document is still loading, which `loading()` makes it.
|
|
232
|
+
*/
|
|
233
|
+
function streamed(html: string, content: string) {
|
|
234
|
+
const pending = html.replace(
|
|
235
|
+
`<!--$-->${content}<!--/$-->`,
|
|
236
|
+
'<!--$?--><template id="B:0"></template><p>fallback</p><!--/$-->'
|
|
237
|
+
)
|
|
238
|
+
expect(pending).not.toBe(html)
|
|
239
|
+
return pending
|
|
240
|
+
}
|
|
241
|
+
async function whileLoading(run: () => Promise<void>) {
|
|
242
|
+
Object.defineProperty(document, "readyState", {
|
|
243
|
+
configurable: true,
|
|
244
|
+
get: () => "loading",
|
|
245
|
+
})
|
|
246
|
+
try {
|
|
247
|
+
await run()
|
|
248
|
+
} finally {
|
|
249
|
+
delete (document as unknown as { readyState?: string }).readyState
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
describe("hydrating with a stored session", () => {
|
|
254
|
+
it.each([
|
|
255
|
+
["", (page: ReactElement) => page],
|
|
256
|
+
[
|
|
257
|
+
" under StrictMode",
|
|
258
|
+
(page: ReactElement) => <StrictMode>{page}</StrictMode>,
|
|
259
|
+
],
|
|
260
|
+
])(
|
|
261
|
+
"renders the server's keys, sends nothing during hydration, then reads once as the user%s",
|
|
262
|
+
async (_, wrap) => {
|
|
263
|
+
const { server, json, html } = await serverRender()
|
|
264
|
+
expect(html).toContain(`read by ${ANONYMOUS}`)
|
|
265
|
+
expect(whoamiCallers(server)).toEqual([ANONYMOUS])
|
|
266
|
+
container.innerHTML = html
|
|
267
|
+
const served = container.querySelector("main")
|
|
268
|
+
|
|
269
|
+
const browser = replica({ seed: 7 })
|
|
270
|
+
const user = browser.auth.getPrincipal()?.toText()
|
|
271
|
+
expect(browser.client.caller()).toBe(user)
|
|
272
|
+
const commits = watchCommits()
|
|
273
|
+
const { recoverable, logged } = await hydrate(
|
|
274
|
+
wrap(page(browser.client, JSON.parse(json), Reader))
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
// The first render matched the server's HTML: React kept its nodes and
|
|
278
|
+
// reported nothing.
|
|
279
|
+
expect(recoverable).toEqual([])
|
|
280
|
+
expect(logged).not.toHaveBeenCalled()
|
|
281
|
+
// Right after hydration the reader renders the user's keys, and reads
|
|
282
|
+
// them once, as the user. The anonymous read the server made is not sent
|
|
283
|
+
// again, and its answer is never shown as the user's.
|
|
284
|
+
await waitFor(() =>
|
|
285
|
+
expect(container.querySelector("main")?.textContent).toBe(
|
|
286
|
+
`read by ${user}`
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
commits.stop()
|
|
290
|
+
expect(container.querySelector("main")).toBe(served)
|
|
291
|
+
expect(whoamiCallers(browser)).toEqual([user])
|
|
292
|
+
expect(container.querySelector("header")?.textContent).toBe("signed-in")
|
|
293
|
+
expect(commits.commits.filter((html) => html.includes("ERROR"))).toEqual(
|
|
294
|
+
[]
|
|
295
|
+
)
|
|
296
|
+
}
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
it("hydrates a provider given a spread copy of the client as one given the client", async () => {
|
|
300
|
+
const { json, html } = await serverRender()
|
|
301
|
+
container.innerHTML = html
|
|
302
|
+
const served = container.querySelector("main")
|
|
303
|
+
const browser = replica({ seed: 7 })
|
|
304
|
+
const user = browser.auth.getPrincipal()?.toText()
|
|
305
|
+
// A structural copy, as a test's double of a client often is.
|
|
306
|
+
const copy: Client = { ...browser.client }
|
|
307
|
+
|
|
308
|
+
const { recoverable, logged } = await hydrate(
|
|
309
|
+
page(copy, JSON.parse(json), Reader)
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
expect(recoverable).toEqual([])
|
|
313
|
+
expect(logged).not.toHaveBeenCalled()
|
|
314
|
+
await waitFor(() =>
|
|
315
|
+
expect(container.querySelector("main")?.textContent).toBe(
|
|
316
|
+
`read by ${user}`
|
|
317
|
+
)
|
|
318
|
+
)
|
|
319
|
+
expect(container.querySelector("main")).toBe(served)
|
|
320
|
+
expect(whoamiCallers(browser)).toEqual([user])
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it("hydrates a late boundary below no component that renders with the caller from the server's keys, then moves it to the user", async () => {
|
|
324
|
+
const { json, html } = await serverRender()
|
|
325
|
+
container.innerHTML = html
|
|
326
|
+
const served = container.querySelector("main")
|
|
327
|
+
|
|
328
|
+
const { Late, deliver } = lateChunk(Reader)
|
|
329
|
+
const browser = replica({ seed: 7 })
|
|
330
|
+
const user = browser.auth.getPrincipal()?.toText()
|
|
331
|
+
const { recoverable, logged } = await hydrate(
|
|
332
|
+
page(browser.client, JSON.parse(json), Late)
|
|
333
|
+
)
|
|
334
|
+
// The root hydrated and its header moved on to the session; the boundary
|
|
335
|
+
// is still the server's.
|
|
336
|
+
expect(container.querySelector("header")?.textContent).toBe("signed-in")
|
|
337
|
+
expect(container.querySelector("main")?.textContent).toBe(
|
|
338
|
+
`read by ${ANONYMOUS}`
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
await act(async () => deliver())
|
|
342
|
+
await act(macrotask)
|
|
343
|
+
|
|
344
|
+
expect(recoverable).toEqual([])
|
|
345
|
+
expect(logged).not.toHaveBeenCalled()
|
|
346
|
+
await waitFor(() =>
|
|
347
|
+
expect(container.querySelector("main")?.textContent).toBe(
|
|
348
|
+
`read by ${user}`
|
|
349
|
+
)
|
|
350
|
+
)
|
|
351
|
+
expect(container.querySelector("main")).toBe(served)
|
|
352
|
+
expect(whoamiCallers(browser)).toEqual([user])
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
it.each([
|
|
356
|
+
["a read the server did not prefetch (isLoading)", false],
|
|
357
|
+
["a stale read the server prefetched (isFetching)", true],
|
|
358
|
+
] as const)(
|
|
359
|
+
"hydrates a component that renders the fetch state of %s, then loads the user's read",
|
|
360
|
+
async (_, prefetched) => {
|
|
361
|
+
function Status() {
|
|
362
|
+
const client = useClient()
|
|
363
|
+
const who = client.canister<Who>(WHO, { id: CANISTER })
|
|
364
|
+
const read = useQuery(client.queryOptions(who, "whoami"))
|
|
365
|
+
return (
|
|
366
|
+
<main>
|
|
367
|
+
{`${read.isLoading ? "loading" : "settled"}|${
|
|
368
|
+
read.isFetching ? "fetching" : "idle"
|
|
369
|
+
}|${read.fetchStatus}|${read.data ?? "none"}`}
|
|
370
|
+
</main>
|
|
371
|
+
)
|
|
372
|
+
}
|
|
373
|
+
const tree = (client: Client, state: DehydratedState) => (
|
|
374
|
+
<ReactorProvider client={() => client}>
|
|
375
|
+
<HydrationBoundary state={state}>
|
|
376
|
+
<Status />
|
|
377
|
+
</HydrationBoundary>
|
|
378
|
+
</ReactorProvider>
|
|
379
|
+
)
|
|
380
|
+
const server = replica({ signedIn: false })
|
|
381
|
+
if (prefetched) {
|
|
382
|
+
const serverWho = server.client.canister<Who>(WHO, { id: CANISTER })
|
|
383
|
+
await server.client.queryClient.prefetchQuery(
|
|
384
|
+
server.client.queryOptions(serverWho, "whoami")
|
|
385
|
+
)
|
|
386
|
+
}
|
|
387
|
+
const json = JSON.stringify(dehydrate(server.client.queryClient))
|
|
388
|
+
container.innerHTML = renderToString(
|
|
389
|
+
tree(server.client, JSON.parse(json))
|
|
390
|
+
)
|
|
391
|
+
const served = container.querySelector("main")
|
|
392
|
+
|
|
393
|
+
const browser = replica({ seed: 7 })
|
|
394
|
+
const user = browser.auth.getPrincipal()?.toText()
|
|
395
|
+
const { recoverable, logged } = await hydrate(
|
|
396
|
+
tree(browser.client, JSON.parse(json))
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
expect(recoverable).toEqual([])
|
|
400
|
+
expect(logged).not.toHaveBeenCalled()
|
|
401
|
+
await waitFor(() =>
|
|
402
|
+
expect(container.querySelector("main")?.textContent).toBe(
|
|
403
|
+
`settled|idle|idle|read by ${user}`
|
|
404
|
+
)
|
|
405
|
+
)
|
|
406
|
+
expect(container.querySelector("main")).toBe(served)
|
|
407
|
+
expect(whoamiCallers(browser)).toEqual([user])
|
|
408
|
+
}
|
|
409
|
+
)
|
|
410
|
+
})
|
|
411
|
+
|
|
412
|
+
describe("useClient()", () => {
|
|
413
|
+
it("renders a component that uses only it again on sign-in, switch and sign-out, with the new caller's keys", async () => {
|
|
414
|
+
const test = replica({ seed: 1, signedIn: false })
|
|
415
|
+
const renders: string[] = []
|
|
416
|
+
function Keys() {
|
|
417
|
+
const client = useClient()
|
|
418
|
+
const who = client.canister<Who>(WHO, { id: CANISTER })
|
|
419
|
+
const key = client.queryKey(who, "whoami")
|
|
420
|
+
renders.push(String(key[2]))
|
|
421
|
+
const read = useQuery(client.queryOptions(who, "whoami"))
|
|
422
|
+
return (
|
|
423
|
+
<p data-testid="keys" data-caller={String(key[2])}>
|
|
424
|
+
{read.data ?? "loading"}
|
|
425
|
+
</p>
|
|
426
|
+
)
|
|
427
|
+
}
|
|
428
|
+
render(
|
|
429
|
+
<ReactorProvider client={() => test.client}>
|
|
430
|
+
<Keys />
|
|
431
|
+
</ReactorProvider>
|
|
432
|
+
)
|
|
433
|
+
const shown = () => screen.getByTestId("keys")
|
|
434
|
+
await waitFor(() =>
|
|
435
|
+
expect(shown().textContent).toBe(`read by ${ANONYMOUS}`)
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
// Each change comes from the auth, not from this tree: nothing else
|
|
439
|
+
// renders the component again.
|
|
440
|
+
await act(async () => {
|
|
441
|
+
await test.auth.signIn(1)
|
|
442
|
+
})
|
|
443
|
+
const first = test.auth.getPrincipal()?.toText()
|
|
444
|
+
expect(shown().dataset.caller).toBe(first)
|
|
445
|
+
await waitFor(() => expect(shown().textContent).toBe(`read by ${first}`))
|
|
446
|
+
|
|
447
|
+
act(() => {
|
|
448
|
+
test.auth.switchTo(2)
|
|
449
|
+
})
|
|
450
|
+
const second = test.auth.getPrincipal()?.toText()
|
|
451
|
+
expect(second).not.toBe(first)
|
|
452
|
+
expect(shown().dataset.caller).toBe(second)
|
|
453
|
+
await waitFor(() => expect(shown().textContent).toBe(`read by ${second}`))
|
|
454
|
+
|
|
455
|
+
await act(async () => {
|
|
456
|
+
await test.auth.signOut()
|
|
457
|
+
})
|
|
458
|
+
expect(shown().dataset.caller).toBe(ANONYMOUS)
|
|
459
|
+
expect(shown().textContent).toBe(`read by ${ANONYMOUS}`)
|
|
460
|
+
await waitFor(() => expect(whoamiCallers(test)).toHaveLength(4))
|
|
461
|
+
|
|
462
|
+
// Every read was sent as the caller whose key it fills: the anonymous key
|
|
463
|
+
// read again on its return, as it is stale.
|
|
464
|
+
expect(whoamiCallers(test)).toEqual([ANONYMOUS, first, second, ANONYMOUS])
|
|
465
|
+
// The component rendered with each caller's keys, in turn.
|
|
466
|
+
const callers = renders.filter((key, at) => key !== renders[at - 1])
|
|
467
|
+
expect(callers).toEqual([ANONYMOUS, first, second, ANONYMOUS])
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
it("returns one object per caller: another after a sign-in, a switch, a sign-out and an expiry, the same across renders for one caller", async () => {
|
|
471
|
+
const test = replica({ seed: 1 })
|
|
472
|
+
const seen: Client[] = []
|
|
473
|
+
let renderAgain!: () => void
|
|
474
|
+
function Probe() {
|
|
475
|
+
const [, setTick] = useState(0)
|
|
476
|
+
renderAgain = () => setTick((tick) => tick + 1)
|
|
477
|
+
seen.push(useClient())
|
|
478
|
+
return <p data-testid="status">{useAuth().status}</p>
|
|
479
|
+
}
|
|
480
|
+
render(
|
|
481
|
+
<ReactorProvider client={() => test.client}>
|
|
482
|
+
<Probe />
|
|
483
|
+
</ReactorProvider>
|
|
484
|
+
)
|
|
485
|
+
/** What the last render got, once the tree rendered again for no change of caller. */
|
|
486
|
+
const settle = () => {
|
|
487
|
+
const before = seen.length
|
|
488
|
+
act(() => renderAgain())
|
|
489
|
+
expect(seen.length).toBeGreaterThan(before)
|
|
490
|
+
return seen[seen.length - 1] as Client
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const first = settle()
|
|
494
|
+
const user = test.auth.getPrincipal()?.toText()
|
|
495
|
+
act(() => {
|
|
496
|
+
test.auth.switchTo(2)
|
|
497
|
+
})
|
|
498
|
+
const switched = settle()
|
|
499
|
+
const second = test.auth.getPrincipal()?.toText()
|
|
500
|
+
await act(async () => {
|
|
501
|
+
await test.auth.signOut()
|
|
502
|
+
})
|
|
503
|
+
const signedOut = settle()
|
|
504
|
+
await act(async () => {
|
|
505
|
+
await test.auth.signIn(3)
|
|
506
|
+
})
|
|
507
|
+
const signedIn = settle()
|
|
508
|
+
const third = test.auth.getPrincipal()?.toText()
|
|
509
|
+
// The live caller's view says what the client says.
|
|
510
|
+
expect(signedIn.authState()).toBe(test.client.authState())
|
|
511
|
+
act(() => {
|
|
512
|
+
test.auth.expire()
|
|
513
|
+
})
|
|
514
|
+
const expired = settle()
|
|
515
|
+
|
|
516
|
+
expect(screen.getByTestId("status").textContent).toBe("expired")
|
|
517
|
+
// Each render for one caller got that caller's object, and no other.
|
|
518
|
+
const byCaller = new Map<string, Set<Client>>()
|
|
519
|
+
for (const client of seen) {
|
|
520
|
+
const objects = byCaller.get(client.caller()) ?? new Set()
|
|
521
|
+
byCaller.set(client.caller(), objects.add(client))
|
|
522
|
+
}
|
|
523
|
+
expect([...byCaller.keys()]).toEqual([user, second, ANONYMOUS, third])
|
|
524
|
+
for (const objects of byCaller.values()) expect(objects.size).toBe(1)
|
|
525
|
+
// A new object at every change of caller, and the anonymous one again
|
|
526
|
+
// when the caller turns anonymous, however it does.
|
|
527
|
+
expect(new Set([first, switched, signedOut, signedIn]).size).toBe(4)
|
|
528
|
+
expect(expired).toBe(signedOut)
|
|
529
|
+
expect(
|
|
530
|
+
[first, switched, signedOut, signedIn].map((client) => client.caller())
|
|
531
|
+
).toEqual([user, second, ANONYMOUS, third])
|
|
532
|
+
// Each is a view of the provider's client: its cache, canisters and
|
|
533
|
+
// sign-in are the client's own, and its state the live one.
|
|
534
|
+
for (const client of seen) {
|
|
535
|
+
expect(client).not.toBe(test.client)
|
|
536
|
+
expect(clientOf(client)).toBe(test.client)
|
|
537
|
+
expect(client.queryClient).toBe(test.client.queryClient)
|
|
538
|
+
expect(client.canister).toBe(test.client.canister)
|
|
539
|
+
expect(client.subscribe).toBe(test.client.subscribe)
|
|
540
|
+
expect(client.signOut).toBe(test.client.signOut)
|
|
541
|
+
}
|
|
542
|
+
expect(expired.authState()).toBe(test.client.authState())
|
|
543
|
+
expect(expired.authState().status).toBe("expired")
|
|
544
|
+
})
|
|
545
|
+
|
|
546
|
+
// #894: what a component memoizes on the client (useMemo, an effect's
|
|
547
|
+
// dependencies, React Compiler's cache) is built again when the caller
|
|
548
|
+
// changes, because the client it gets changes with it.
|
|
549
|
+
it("rebuilds what a component memoizes on the client after a switch: useMemo, an effect, and React Compiler's output", async () => {
|
|
550
|
+
const test = replica({ seed: 1 })
|
|
551
|
+
function Memoized() {
|
|
552
|
+
const client = useClient()
|
|
553
|
+
const who = client.canister<Who>(WHO, { id: CANISTER })
|
|
554
|
+
const options = useMemo(
|
|
555
|
+
() => client.queryOptions(who, "whoami"),
|
|
556
|
+
[client, who]
|
|
557
|
+
)
|
|
558
|
+
const read = useQuery(options)
|
|
559
|
+
return (
|
|
560
|
+
<p data-testid="memo">
|
|
561
|
+
{read.isError ? "ERROR" : (read.data ?? "loading")}
|
|
562
|
+
</p>
|
|
563
|
+
)
|
|
564
|
+
}
|
|
565
|
+
function Effect() {
|
|
566
|
+
const client = useClient()
|
|
567
|
+
const [shown, setShown] = useState("loading")
|
|
568
|
+
useEffect(() => {
|
|
569
|
+
const who = client.canister<Who>(WHO, { id: CANISTER })
|
|
570
|
+
let current = true
|
|
571
|
+
setShown("loading")
|
|
572
|
+
client.queryClient.fetchQuery(client.queryOptions(who, "whoami")).then(
|
|
573
|
+
(data) => current && setShown(data),
|
|
574
|
+
() => current && setShown("ERROR")
|
|
575
|
+
)
|
|
576
|
+
return () => {
|
|
577
|
+
current = false
|
|
578
|
+
}
|
|
579
|
+
}, [client])
|
|
580
|
+
return <p data-testid="effect">{shown}</p>
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* What React Compiler 1.0 emits for
|
|
584
|
+
* `useQuery(client.queryOptions(who, "whoami"))`, written by hand: its
|
|
585
|
+
* cache is the `useState` array of `react-compiler-runtime`'s `c()`
|
|
586
|
+
* (React 19's own `c` keeps the same array on the fiber), and a slot is
|
|
587
|
+
* computed again only when an input it read changed by identity.
|
|
588
|
+
*/
|
|
589
|
+
function Compiled() {
|
|
590
|
+
const $ = useState(() => new Array<unknown>(5).fill(EMPTY))[0]
|
|
591
|
+
const client = useClient()
|
|
592
|
+
let who: Canister<Who>
|
|
593
|
+
if ($[0] !== client) {
|
|
594
|
+
who = client.canister<Who>(WHO, { id: CANISTER })
|
|
595
|
+
$[0] = client
|
|
596
|
+
$[1] = who
|
|
597
|
+
} else {
|
|
598
|
+
who = $[1] as Canister<Who>
|
|
599
|
+
}
|
|
600
|
+
let options: WhoamiOptions
|
|
601
|
+
if ($[2] !== client || $[3] !== who) {
|
|
602
|
+
options = whoamiOptions(client, who)
|
|
603
|
+
$[2] = client
|
|
604
|
+
$[3] = who
|
|
605
|
+
$[4] = options
|
|
606
|
+
} else {
|
|
607
|
+
options = $[4] as WhoamiOptions
|
|
608
|
+
}
|
|
609
|
+
const read = useQuery(options)
|
|
610
|
+
return (
|
|
611
|
+
<p data-testid="compiled">
|
|
612
|
+
{read.isError ? "ERROR" : (read.data ?? "loading")}
|
|
613
|
+
</p>
|
|
614
|
+
)
|
|
615
|
+
}
|
|
616
|
+
render(
|
|
617
|
+
<ReactorProvider client={() => test.client}>
|
|
618
|
+
<Memoized />
|
|
619
|
+
<Effect />
|
|
620
|
+
<Compiled />
|
|
621
|
+
</ReactorProvider>
|
|
622
|
+
)
|
|
623
|
+
const all = ["memo", "effect", "compiled"] as const
|
|
624
|
+
const shown = () => all.map((id) => screen.getByTestId(id).textContent)
|
|
625
|
+
const first = test.auth.getPrincipal()?.toText()
|
|
626
|
+
await waitFor(() =>
|
|
627
|
+
expect(shown()).toEqual(all.map(() => `read by ${first}`))
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
act(() => {
|
|
631
|
+
test.auth.switchTo(2)
|
|
632
|
+
})
|
|
633
|
+
const second = test.auth.getPrincipal()?.toText()
|
|
634
|
+
expect(second).not.toBe(first)
|
|
635
|
+
// Never the previous caller's data under the new caller.
|
|
636
|
+
for (const text of shown()) expect(text).not.toBe(`read by ${first}`)
|
|
637
|
+
await waitFor(() =>
|
|
638
|
+
expect(shown()).toEqual(all.map(() => `read by ${second}`))
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
// A refetch reads as the caller now, and is never refused.
|
|
642
|
+
await act(() => test.client.queryClient.invalidateQueries())
|
|
643
|
+
for (let tick = 0; tick < 3; tick++) await act(macrotask)
|
|
644
|
+
expect(shown()).toEqual(all.map(() => `read by ${second}`))
|
|
645
|
+
expect(new Set(whoamiCallers(test).slice(-2))).toEqual(new Set([second]))
|
|
646
|
+
})
|
|
647
|
+
|
|
648
|
+
// #894: the client a render got writes as that render's caller or not at
|
|
649
|
+
// all, so a press handled before a switch reaches the render is refused
|
|
650
|
+
// instead of signed by the new caller, with no guard of the app's.
|
|
651
|
+
it("refuses a write pressed in the batch of a switch, before the render shows the new caller, and sends nothing", async () => {
|
|
652
|
+
const test = replica({ seed: 1 })
|
|
653
|
+
const failures: unknown[] = []
|
|
654
|
+
function Stamp() {
|
|
655
|
+
const client = useClient()
|
|
656
|
+
const who = client.canister<Who>(WHO, { id: CANISTER })
|
|
657
|
+
const stamp = useMutation({
|
|
658
|
+
...client.mutationOptions(who, "stamp"),
|
|
659
|
+
onError: (error) => failures.push(error),
|
|
660
|
+
})
|
|
661
|
+
return (
|
|
662
|
+
<button data-status={stamp.status} onClick={() => stamp.mutate()}>
|
|
663
|
+
{stamp.data ?? `Stamp as ${client.caller()}`}
|
|
664
|
+
</button>
|
|
665
|
+
)
|
|
666
|
+
}
|
|
667
|
+
render(
|
|
668
|
+
<ReactorProvider client={() => test.client}>
|
|
669
|
+
<Stamp />
|
|
670
|
+
</ReactorProvider>
|
|
671
|
+
)
|
|
672
|
+
const first = test.auth.getPrincipal()?.toText()
|
|
673
|
+
const button = screen.getByRole("button", { name: `Stamp as ${first}` })
|
|
674
|
+
const stamps = () =>
|
|
675
|
+
test.requests.filter(
|
|
676
|
+
({ methodName, endpoint }) =>
|
|
677
|
+
methodName === "stamp" && endpoint === "call"
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
act(() => {
|
|
681
|
+
test.auth.switchTo(2)
|
|
682
|
+
fireEvent.click(button)
|
|
683
|
+
})
|
|
684
|
+
const second = test.auth.getPrincipal()?.toText()
|
|
685
|
+
await screen.findByRole("button", { name: `Stamp as ${second}` })
|
|
686
|
+
await waitFor(() => expect(failures).toHaveLength(1))
|
|
687
|
+
expect(failures[0]).toMatchObject({
|
|
688
|
+
kind: "cancelled",
|
|
689
|
+
code: "caller_changed",
|
|
690
|
+
mayHaveExecuted: false,
|
|
691
|
+
})
|
|
692
|
+
expect(stamps()).toEqual([])
|
|
693
|
+
// The refusal reaches the options the press used (their onError); the
|
|
694
|
+
// mutation the new render shows starts over for the new caller, idle:
|
|
695
|
+
// the key holds the caller, so TanStack detached it from the stale run.
|
|
696
|
+
expect(
|
|
697
|
+
screen.getByRole("button", { name: `Stamp as ${second}` }).dataset.status
|
|
698
|
+
).toBe("idle")
|
|
699
|
+
|
|
700
|
+
// The render that shows the new caller writes as them.
|
|
701
|
+
fireEvent.click(screen.getByRole("button", { name: `Stamp as ${second}` }))
|
|
702
|
+
await screen.findByRole("button", { name: `written by ${second}` })
|
|
703
|
+
expect(stamps().map(({ caller }) => caller)).toEqual([second])
|
|
704
|
+
})
|
|
705
|
+
|
|
706
|
+
// #894: a handler that signs in and then writes presses with the options
|
|
707
|
+
// of the last render React committed. The sign-in's update renders and
|
|
708
|
+
// commits before the handler goes on, so the write goes out as the user;
|
|
709
|
+
// when that render cannot commit yet (it suspends on the user's data), the
|
|
710
|
+
// press still has the anonymous caller's options and is refused, sending
|
|
711
|
+
// nothing. A direct call signs as the caller current either way.
|
|
712
|
+
it.each([
|
|
713
|
+
["commits before the handler goes on", false, "sent as the user"],
|
|
714
|
+
["suspends on the user's data", true, "refused"],
|
|
715
|
+
])(
|
|
716
|
+
"decides a write pressed right after awaiting a sign-in by whether the signed-in render %s: %s",
|
|
717
|
+
async (_, suspends, outcome) => {
|
|
718
|
+
const test = replica({ seed: 1, signedIn: false })
|
|
719
|
+
const failures: unknown[] = []
|
|
720
|
+
let direct: Promise<string> | undefined
|
|
721
|
+
function Read({ client }: { client: Client }) {
|
|
722
|
+
const who = client.canister<Who>(WHO, { id: CANISTER })
|
|
723
|
+
return (
|
|
724
|
+
<p>{useSuspenseQuery(client.queryOptions(who, "whoami")).data}</p>
|
|
725
|
+
)
|
|
726
|
+
}
|
|
727
|
+
function SignInAndStamp() {
|
|
728
|
+
const client = useClient()
|
|
729
|
+
const who = client.canister<Who>(WHO, { id: CANISTER })
|
|
730
|
+
const stamp = useMutation({
|
|
731
|
+
...client.mutationOptions(who, "stamp"),
|
|
732
|
+
onError: (error) => failures.push(error),
|
|
733
|
+
})
|
|
734
|
+
const signInAndStamp = async () => {
|
|
735
|
+
await client.signIn(1)
|
|
736
|
+
stamp.mutate()
|
|
737
|
+
}
|
|
738
|
+
const signInAndCall = async () => {
|
|
739
|
+
await client.signIn(1)
|
|
740
|
+
direct = who.stamp()
|
|
741
|
+
}
|
|
742
|
+
return (
|
|
743
|
+
<>
|
|
744
|
+
{suspends && <Read client={client} />}
|
|
745
|
+
<button onClick={() => void signInAndStamp()}>
|
|
746
|
+
sign in, mutate
|
|
747
|
+
</button>
|
|
748
|
+
<button onClick={() => void signInAndCall()}>sign in, call</button>
|
|
749
|
+
<output>{stamp.data ?? stamp.status}</output>
|
|
750
|
+
</>
|
|
751
|
+
)
|
|
752
|
+
}
|
|
753
|
+
render(
|
|
754
|
+
<ReactorProvider client={() => test.client}>
|
|
755
|
+
<Suspense fallback={<p>loading</p>}>
|
|
756
|
+
<SignInAndStamp />
|
|
757
|
+
</Suspense>
|
|
758
|
+
</ReactorProvider>
|
|
759
|
+
)
|
|
760
|
+
await screen.findByRole("button", { name: "sign in, mutate" })
|
|
761
|
+
const stamps = () =>
|
|
762
|
+
test.requests
|
|
763
|
+
.filter(
|
|
764
|
+
({ methodName, endpoint }) =>
|
|
765
|
+
methodName === "stamp" && endpoint === "call"
|
|
766
|
+
)
|
|
767
|
+
.map(({ caller }) => caller)
|
|
768
|
+
|
|
769
|
+
fireEvent.click(screen.getByRole("button", { name: "sign in, mutate" }))
|
|
770
|
+
for (let tick = 0; tick < 5; tick++) await act(macrotask)
|
|
771
|
+
const user = test.auth.getPrincipal()?.toText()
|
|
772
|
+
if (outcome === "refused") {
|
|
773
|
+
await waitFor(() => expect(failures).toHaveLength(1))
|
|
774
|
+
expect(failures[0]).toMatchObject({
|
|
775
|
+
kind: "cancelled",
|
|
776
|
+
code: "caller_changed",
|
|
777
|
+
mayHaveExecuted: false,
|
|
778
|
+
})
|
|
779
|
+
expect(stamps()).toEqual([])
|
|
780
|
+
} else {
|
|
781
|
+
await screen.findByText(`written by ${user}`)
|
|
782
|
+
expect(failures).toEqual([])
|
|
783
|
+
expect(stamps()).toEqual([user])
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// A direct call made after the sign-in signs as the user either way.
|
|
787
|
+
await act(async () => {
|
|
788
|
+
await test.client.signOut()
|
|
789
|
+
})
|
|
790
|
+
await screen.findByRole("button", { name: "sign in, call" })
|
|
791
|
+
fireEvent.click(screen.getByRole("button", { name: "sign in, call" }))
|
|
792
|
+
for (let tick = 0; tick < 5; tick++) await act(macrotask)
|
|
793
|
+
await expect(direct).resolves.toBe(`written by ${user}`)
|
|
794
|
+
expect(stamps()[stamps().length - 1]).toBe(user)
|
|
795
|
+
}
|
|
796
|
+
)
|
|
797
|
+
|
|
798
|
+
it("returns a view per caller after a hydration too: the server's caller for the hydrating render, the user's after it", async () => {
|
|
799
|
+
const { json, html } = await serverRender()
|
|
800
|
+
container.innerHTML = html
|
|
801
|
+
const browser = replica({ seed: 7 })
|
|
802
|
+
const user = browser.auth.getPrincipal()?.toText()
|
|
803
|
+
const seen: Client[] = []
|
|
804
|
+
function Probe() {
|
|
805
|
+
const client = useClient()
|
|
806
|
+
seen.push(client)
|
|
807
|
+
return <Reader />
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
await hydrate(page(browser.client, JSON.parse(json), Probe))
|
|
811
|
+
|
|
812
|
+
expect(seen).toHaveLength(2)
|
|
813
|
+
const [hydrating, after] = seen as [Client, Client]
|
|
814
|
+
// The hydrating render's view: the server's caller, the client's own
|
|
815
|
+
// cache and canisters.
|
|
816
|
+
expect(hydrating).not.toBe(browser.client)
|
|
817
|
+
expect(hydrating.caller()).toBe(ANONYMOUS)
|
|
818
|
+
expect(hydrating.authState()).toEqual({
|
|
819
|
+
status: "anonymous",
|
|
820
|
+
principal: ANONYMOUS,
|
|
821
|
+
})
|
|
822
|
+
expect(hydrating.queryClient).toBe(browser.client.queryClient)
|
|
823
|
+
expect(hydrating.canister).toBe(browser.client.canister)
|
|
824
|
+
expect(hydrating.subscribe).toBe(browser.client.subscribe)
|
|
825
|
+
// Then the user's view: another object, over the same client.
|
|
826
|
+
expect(after).not.toBe(hydrating)
|
|
827
|
+
expect(clientOf(after)).toBe(browser.client)
|
|
828
|
+
expect(after.caller()).toBe(user)
|
|
829
|
+
expect(after.authState()).toBe(browser.client.authState())
|
|
830
|
+
})
|
|
831
|
+
|
|
832
|
+
it("reads as nobody when the caller turns anonymous before the hydrating render's effects", async () => {
|
|
833
|
+
function Nav() {
|
|
834
|
+
const client = useClient()
|
|
835
|
+
const who = client.canister<Who>(WHO, { id: CANISTER })
|
|
836
|
+
const read = useQuery(client.queryOptions(who, "whoami"))
|
|
837
|
+
return <main>{read.isError ? "ERROR" : (read.data ?? "loading")}</main>
|
|
838
|
+
}
|
|
839
|
+
let expire!: () => void
|
|
840
|
+
function Expire() {
|
|
841
|
+
useLayoutEffect(() => expire(), [])
|
|
842
|
+
return null
|
|
843
|
+
}
|
|
844
|
+
const tree = (client: Client) => (
|
|
845
|
+
<ReactorProvider client={() => client}>
|
|
846
|
+
<Nav />
|
|
847
|
+
<Expire />
|
|
848
|
+
</ReactorProvider>
|
|
849
|
+
)
|
|
850
|
+
const server = replica({ signedIn: false })
|
|
851
|
+
expire = () => {}
|
|
852
|
+
container.innerHTML = renderToString(tree(server.client))
|
|
853
|
+
const browser = replica({ seed: 7 })
|
|
854
|
+
expire = () => browser.auth.expire()
|
|
855
|
+
|
|
856
|
+
const { recoverable } = await hydrate(tree(browser.client))
|
|
857
|
+
|
|
858
|
+
expect(recoverable).toEqual([])
|
|
859
|
+
await waitFor(() =>
|
|
860
|
+
expect(container.querySelector("main")?.textContent).toBe(
|
|
861
|
+
`read by ${ANONYMOUS}`
|
|
862
|
+
)
|
|
863
|
+
)
|
|
864
|
+
expect(whoamiCallers(browser)).toEqual([ANONYMOUS])
|
|
865
|
+
})
|
|
866
|
+
|
|
867
|
+
// N2, documented misuse: the guides say to read useClient() in render, and
|
|
868
|
+
// pin what keeping its hydrating value does, so that a change to it shows.
|
|
869
|
+
it("leaves a value kept from the hydrating render (state, ref, module variable) the anonymous view for good", async () => {
|
|
870
|
+
let module: Client | undefined
|
|
871
|
+
const kept: Client[] = []
|
|
872
|
+
function Keeper() {
|
|
873
|
+
const client = useClient()
|
|
874
|
+
const [state] = useState(client)
|
|
875
|
+
const ref = useRef(client)
|
|
876
|
+
module ??= client
|
|
877
|
+
kept.splice(0, 3, state, ref.current, module)
|
|
878
|
+
const who = state.canister<Who>(WHO, { id: CANISTER })
|
|
879
|
+
const read = useQuery(state.queryOptions(who, "whoami"))
|
|
880
|
+
return (
|
|
881
|
+
<main data-live={client.caller()} data-kept={state.caller()}>
|
|
882
|
+
{read.isError ? "ERROR" : (read.data ?? "loading")}
|
|
883
|
+
</main>
|
|
884
|
+
)
|
|
885
|
+
}
|
|
886
|
+
const tree = (client: Client, state: DehydratedState) => (
|
|
887
|
+
<ReactorProvider client={() => client}>
|
|
888
|
+
<HydrationBoundary state={state}>
|
|
889
|
+
<Keeper />
|
|
890
|
+
</HydrationBoundary>
|
|
891
|
+
</ReactorProvider>
|
|
892
|
+
)
|
|
893
|
+
const server = replica({ signedIn: false })
|
|
894
|
+
const serverWho = server.client.canister<Who>(WHO, { id: CANISTER })
|
|
895
|
+
await server.client.queryClient.prefetchQuery(
|
|
896
|
+
server.client.queryOptions(serverWho, "whoami")
|
|
897
|
+
)
|
|
898
|
+
const json = JSON.stringify(dehydrate(server.client.queryClient))
|
|
899
|
+
container.innerHTML = renderToString(tree(server.client, JSON.parse(json)))
|
|
900
|
+
module = undefined
|
|
901
|
+
const browser = replica({ seed: 7 })
|
|
902
|
+
const user = browser.auth.getPrincipal()?.toText()
|
|
903
|
+
|
|
904
|
+
const { recoverable } = await hydrate(
|
|
905
|
+
tree(browser.client, JSON.parse(json))
|
|
906
|
+
)
|
|
907
|
+
for (let tick = 0; tick < 3; tick++) await act(macrotask)
|
|
908
|
+
|
|
909
|
+
expect(recoverable).toEqual([])
|
|
910
|
+
const shown = () => container.querySelector("main")
|
|
911
|
+
// The component rendered again with the client, for the user, but what
|
|
912
|
+
// it kept is the hydrating render's view, and shows the server's data.
|
|
913
|
+
expect(shown()?.dataset.live).toBe(user)
|
|
914
|
+
expect(shown()?.dataset.kept).toBe(ANONYMOUS)
|
|
915
|
+
expect(shown()?.textContent).toBe(`read by ${ANONYMOUS}`)
|
|
916
|
+
expect(kept).toHaveLength(3)
|
|
917
|
+
const who = browser.client.canister<Who>(WHO, { id: CANISTER })
|
|
918
|
+
for (const value of kept) {
|
|
919
|
+
expect(value).not.toBe(browser.client)
|
|
920
|
+
expect(value.caller()).toBe(ANONYMOUS)
|
|
921
|
+
expect(value.queryKey(who, "whoami")).toContain(ANONYMOUS)
|
|
922
|
+
}
|
|
923
|
+
// A read it starts while the user is signed in is cancelled, and sends
|
|
924
|
+
// nothing as anyone.
|
|
925
|
+
await act(() => browser.client.queryClient.invalidateQueries())
|
|
926
|
+
for (let tick = 0; tick < 3; tick++) await act(macrotask)
|
|
927
|
+
expect(shown()?.textContent).toBe(`read by ${ANONYMOUS}`)
|
|
928
|
+
expect(whoamiCallers(browser)).toEqual([])
|
|
929
|
+
})
|
|
930
|
+
|
|
931
|
+
it.each([false, true])(
|
|
932
|
+
"hands a nested ReactorProvider given it the client itself, which reads as the user after a signed-in reload and follows a sign-out and a sign-in (StrictMode: %s)",
|
|
933
|
+
async (strict) => {
|
|
934
|
+
const seen: Client[] = []
|
|
935
|
+
function Inner() {
|
|
936
|
+
const client = useClient()
|
|
937
|
+
seen.push(client)
|
|
938
|
+
const who = client.canister<Who>(WHO, { id: CANISTER })
|
|
939
|
+
const read = useQuery(client.queryOptions(who, "whoami"))
|
|
940
|
+
return (
|
|
941
|
+
<main data-caller={client.caller()}>
|
|
942
|
+
{read.isError ? "ERROR" : (read.data ?? "loading")}
|
|
943
|
+
</main>
|
|
944
|
+
)
|
|
945
|
+
}
|
|
946
|
+
// The hydrating render's useClient() is the anonymous view: the nested
|
|
947
|
+
// provider must hold the client it was made over, not the view.
|
|
948
|
+
function Outer() {
|
|
949
|
+
const client = useClient()
|
|
950
|
+
return (
|
|
951
|
+
<ReactorProvider client={() => client}>
|
|
952
|
+
<Inner />
|
|
953
|
+
</ReactorProvider>
|
|
954
|
+
)
|
|
955
|
+
}
|
|
956
|
+
const tree = (client: Client, state: DehydratedState) => {
|
|
957
|
+
const element = (
|
|
958
|
+
<ReactorProvider client={() => client}>
|
|
959
|
+
<HydrationBoundary state={state}>
|
|
960
|
+
<Outer />
|
|
961
|
+
</HydrationBoundary>
|
|
962
|
+
</ReactorProvider>
|
|
963
|
+
)
|
|
964
|
+
return strict ? <StrictMode>{element}</StrictMode> : element
|
|
965
|
+
}
|
|
966
|
+
const server = replica({ signedIn: false })
|
|
967
|
+
const serverWho = server.client.canister<Who>(WHO, { id: CANISTER })
|
|
968
|
+
await server.client.queryClient.prefetchQuery(
|
|
969
|
+
server.client.queryOptions(serverWho, "whoami")
|
|
970
|
+
)
|
|
971
|
+
const json = JSON.stringify(dehydrate(server.client.queryClient))
|
|
972
|
+
container.innerHTML = renderToString(
|
|
973
|
+
tree(server.client, JSON.parse(json))
|
|
974
|
+
)
|
|
975
|
+
const browser = replica({ seed: 7 })
|
|
976
|
+
const user = browser.auth.getPrincipal()?.toText()
|
|
977
|
+
const shown = () => container.querySelector("main")
|
|
978
|
+
const commits = watchCommits()
|
|
979
|
+
try {
|
|
980
|
+
const { recoverable } = await hydrate(
|
|
981
|
+
tree(browser.client, JSON.parse(json))
|
|
982
|
+
)
|
|
983
|
+
expect(recoverable).toEqual([])
|
|
984
|
+
await waitFor(() =>
|
|
985
|
+
expect(shown()?.textContent).toBe(`read by ${user}`)
|
|
986
|
+
)
|
|
987
|
+
expect(shown()?.dataset.caller).toBe(user)
|
|
988
|
+
for (let tick = 0; tick < 3; tick++) await act(macrotask)
|
|
989
|
+
// StrictMode mounts the reader twice, and each mount may read.
|
|
990
|
+
expect(new Set(whoamiCallers(browser))).toEqual(new Set([user]))
|
|
991
|
+
// The nested provider's tree, past its hydrating render, is on the
|
|
992
|
+
// outer provider's client itself: the user's view of it.
|
|
993
|
+
const last = seen[seen.length - 1] as Client
|
|
994
|
+
expect(clientOf(last)).toBe(browser.client)
|
|
995
|
+
expect(last.caller()).toBe(user)
|
|
996
|
+
|
|
997
|
+
await act(async () => {
|
|
998
|
+
await browser.auth.signOut()
|
|
999
|
+
})
|
|
1000
|
+
await waitFor(() =>
|
|
1001
|
+
expect(shown()?.textContent).toBe(`read by ${ANONYMOUS}`)
|
|
1002
|
+
)
|
|
1003
|
+
expect(shown()?.dataset.caller).toBe(ANONYMOUS)
|
|
1004
|
+
|
|
1005
|
+
await act(async () => {
|
|
1006
|
+
await browser.auth.signIn(3)
|
|
1007
|
+
})
|
|
1008
|
+
const next = browser.auth.getPrincipal()?.toText()
|
|
1009
|
+
expect(next).not.toBe(user)
|
|
1010
|
+
await waitFor(() =>
|
|
1011
|
+
expect(shown()?.textContent).toBe(`read by ${next}`)
|
|
1012
|
+
)
|
|
1013
|
+
expect(shown()?.dataset.caller).toBe(next)
|
|
1014
|
+
expect(
|
|
1015
|
+
commits.commits.filter((html) => html.includes("ERROR"))
|
|
1016
|
+
).toEqual([])
|
|
1017
|
+
} finally {
|
|
1018
|
+
commits.stop()
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
)
|
|
1022
|
+
})
|
|
1023
|
+
|
|
1024
|
+
/**
|
|
1025
|
+
* L1: on a signed-in reload, a Suspense boundary that is still dehydrated
|
|
1026
|
+
* below a component that renders with the caller is rendered on the client
|
|
1027
|
+
* when that component moves on to the session.
|
|
1028
|
+
*/
|
|
1029
|
+
describe("a Suspense boundary still dehydrated below a component that renders with the caller", () => {
|
|
1030
|
+
/** The boundary's content: what the server rendered, and the lazy chunk. */
|
|
1031
|
+
function Content() {
|
|
1032
|
+
return <article>from the server</article>
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/** A nav bar that holds the client for an action, and shows whose keys it would build. */
|
|
1036
|
+
function ActionNav({ Late }: { Late: ComponentType }) {
|
|
1037
|
+
const client = useClient()
|
|
1038
|
+
return (
|
|
1039
|
+
<nav data-caller={client.caller()}>
|
|
1040
|
+
<button onClick={() => void client.signIn()}>sign in</button>
|
|
1041
|
+
<Suspense fallback={<p>fallback</p>}>
|
|
1042
|
+
<Late />
|
|
1043
|
+
</Suspense>
|
|
1044
|
+
</nav>
|
|
1045
|
+
)
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/** The same, following the session as a header does. */
|
|
1049
|
+
function StatusNav({ Late }: { Late: ComponentType }) {
|
|
1050
|
+
const { principal } = useAuth()
|
|
1051
|
+
return (
|
|
1052
|
+
<nav data-caller={principal}>
|
|
1053
|
+
<Suspense fallback={<p>fallback</p>}>
|
|
1054
|
+
<Late />
|
|
1055
|
+
</Suspense>
|
|
1056
|
+
</nav>
|
|
1057
|
+
)
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/** The same, reading with the client, and showing an error if its read fails. */
|
|
1061
|
+
function ReadingNav({ Late }: { Late: ComponentType }) {
|
|
1062
|
+
const client = useClient()
|
|
1063
|
+
const who = client.canister<Who>(WHO, { id: CANISTER })
|
|
1064
|
+
const read = useQuery(client.queryOptions(who, "whoami"))
|
|
1065
|
+
return (
|
|
1066
|
+
<nav data-caller={client.caller()}>
|
|
1067
|
+
<b>{read.isError ? "ERROR" : (read.data ?? "loading")}</b>
|
|
1068
|
+
<Suspense fallback={<p>fallback</p>}>
|
|
1069
|
+
<Late />
|
|
1070
|
+
</Suspense>
|
|
1071
|
+
</nav>
|
|
1072
|
+
)
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
const tree = (
|
|
1076
|
+
client: Client,
|
|
1077
|
+
state: DehydratedState,
|
|
1078
|
+
Nav: ComponentType<{ Late: ComponentType }>,
|
|
1079
|
+
Late: ComponentType
|
|
1080
|
+
) => (
|
|
1081
|
+
<ReactorProvider client={() => client}>
|
|
1082
|
+
<HydrationBoundary state={state}>
|
|
1083
|
+
<Nav Late={Late} />
|
|
1084
|
+
</HydrationBoundary>
|
|
1085
|
+
</ReactorProvider>
|
|
1086
|
+
)
|
|
1087
|
+
|
|
1088
|
+
it.each([
|
|
1089
|
+
["useClient()", ActionNav, false],
|
|
1090
|
+
["useAuth()", StatusNav, false],
|
|
1091
|
+
["useClient() and reads", ReadingNav, false],
|
|
1092
|
+
["useClient() and reads, under StrictMode", ReadingNav, true],
|
|
1093
|
+
] as const)(
|
|
1094
|
+
"is rendered on the client when the parent that calls %s moves on, with the user's data and nothing sent as anyone else",
|
|
1095
|
+
async (_, Nav, strict) => {
|
|
1096
|
+
const server = replica({ signedIn: false })
|
|
1097
|
+
const serverWho = server.client.canister<Who>(WHO, { id: CANISTER })
|
|
1098
|
+
await server.client.queryClient.prefetchQuery(
|
|
1099
|
+
server.client.queryOptions(serverWho, "whoami")
|
|
1100
|
+
)
|
|
1101
|
+
const json = JSON.stringify(dehydrate(server.client.queryClient))
|
|
1102
|
+
const wrap = (element: ReactElement) =>
|
|
1103
|
+
strict ? <StrictMode>{element}</StrictMode> : element
|
|
1104
|
+
container.innerHTML = renderToString(
|
|
1105
|
+
wrap(tree(server.client, JSON.parse(json), Nav, Content))
|
|
1106
|
+
)
|
|
1107
|
+
const served = container.querySelector("article")
|
|
1108
|
+
expect(served?.textContent).toBe("from the server")
|
|
1109
|
+
// Stale, so that mounting the parent's read fetches it again.
|
|
1110
|
+
const state = JSON.parse(json) as DehydratedState
|
|
1111
|
+
for (const query of state.queries) query.state.dataUpdatedAt = 1
|
|
1112
|
+
|
|
1113
|
+
const { Late, deliver } = lateChunk(Content)
|
|
1114
|
+
const browser = replica({ seed: 7 })
|
|
1115
|
+
const user = browser.auth.getPrincipal()?.toText()
|
|
1116
|
+
const errors = watchErrors(browser.client)
|
|
1117
|
+
const commits = watchCommits()
|
|
1118
|
+
try {
|
|
1119
|
+
const { recoverable, logged } = await hydrate(
|
|
1120
|
+
wrap(tree(browser.client, state, Nav, Late))
|
|
1121
|
+
)
|
|
1122
|
+
|
|
1123
|
+
// The parent moved on to the session right after hydrating, and that
|
|
1124
|
+
// update reached the boundary while its chunk was still loading:
|
|
1125
|
+
// React rendered it on the client, so it shows its fallback. React 18
|
|
1126
|
+
// reports it; React 19 does not.
|
|
1127
|
+
expect(container.querySelector("nav")?.dataset.caller).toBe(user)
|
|
1128
|
+
expect(container.querySelector("p")?.textContent).toBe("fallback")
|
|
1129
|
+
expect(served?.isConnected).toBe(false)
|
|
1130
|
+
if (REACT_18) expect(recoverable.length).toBeGreaterThan(0)
|
|
1131
|
+
else expect(recoverable).toEqual([])
|
|
1132
|
+
expect(logged).not.toHaveBeenCalled()
|
|
1133
|
+
|
|
1134
|
+
await act(async () => deliver())
|
|
1135
|
+
await act(macrotask)
|
|
1136
|
+
expect(container.querySelector("article")?.textContent).toBe(
|
|
1137
|
+
"from the server"
|
|
1138
|
+
)
|
|
1139
|
+
if (Nav === ReadingNav) {
|
|
1140
|
+
await waitFor(() =>
|
|
1141
|
+
expect(container.querySelector("b")?.textContent).toBe(
|
|
1142
|
+
`read by ${user}`
|
|
1143
|
+
)
|
|
1144
|
+
)
|
|
1145
|
+
}
|
|
1146
|
+
// Its data is still the user's (D20): nothing was sent as anyone
|
|
1147
|
+
// else, and the cancelled anonymous read, if it ran, was never shown.
|
|
1148
|
+
expect(new Set(whoamiCallers(browser))).toEqual(
|
|
1149
|
+
new Set(Nav === ReadingNav ? [user] : [])
|
|
1150
|
+
)
|
|
1151
|
+
expect(
|
|
1152
|
+
commits.commits.filter((html) => html.includes("ERROR"))
|
|
1153
|
+
).toEqual([])
|
|
1154
|
+
for (const error of errors.errors) {
|
|
1155
|
+
expect(error).toMatchObject({
|
|
1156
|
+
kind: "cancelled",
|
|
1157
|
+
code: "caller_changed",
|
|
1158
|
+
})
|
|
1159
|
+
}
|
|
1160
|
+
} finally {
|
|
1161
|
+
commits.stop()
|
|
1162
|
+
errors.stop()
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
)
|
|
1166
|
+
|
|
1167
|
+
it.each([
|
|
1168
|
+
["useClient()", ActionNav],
|
|
1169
|
+
["useAuth()", StatusNav],
|
|
1170
|
+
["useClient() and reads", ReadingNav],
|
|
1171
|
+
] as const)(
|
|
1172
|
+
"renders a streamed boundary on the client when the parent that calls %s moves on, on React 18 too",
|
|
1173
|
+
async (_, Nav) => {
|
|
1174
|
+
const server = replica({ signedIn: false })
|
|
1175
|
+
const serverWho = server.client.canister<Who>(WHO, { id: CANISTER })
|
|
1176
|
+
await server.client.queryClient.prefetchQuery(
|
|
1177
|
+
server.client.queryOptions(serverWho, "whoami")
|
|
1178
|
+
)
|
|
1179
|
+
const json = JSON.stringify(dehydrate(server.client.queryClient))
|
|
1180
|
+
container.innerHTML = streamed(
|
|
1181
|
+
renderToString(tree(server.client, JSON.parse(json), Nav, Content)),
|
|
1182
|
+
"<article>from the server</article>"
|
|
1183
|
+
)
|
|
1184
|
+
await whileLoading(async () => {
|
|
1185
|
+
const browser = replica({ seed: 7 })
|
|
1186
|
+
const user = browser.auth.getPrincipal()?.toText()
|
|
1187
|
+
const { recoverable, logged } = await hydrate(
|
|
1188
|
+
tree(browser.client, JSON.parse(json), Nav, Content)
|
|
1189
|
+
)
|
|
1190
|
+
await act(macrotask)
|
|
1191
|
+
|
|
1192
|
+
// The move is a synchronous update, and it reached the boundary
|
|
1193
|
+
// before its HTML arrived: React rendered it on the client, and React
|
|
1194
|
+
// 18 reports that.
|
|
1195
|
+
expect(container.querySelector("nav")?.dataset.caller).toBe(user)
|
|
1196
|
+
expect(container.querySelector("template")).toBeNull()
|
|
1197
|
+
expect(container.querySelector("article")?.textContent).toBe(
|
|
1198
|
+
"from the server"
|
|
1199
|
+
)
|
|
1200
|
+
if (REACT_18) expect(recoverable.length).toBeGreaterThan(0)
|
|
1201
|
+
else expect(recoverable).toEqual([])
|
|
1202
|
+
expect(logged).not.toHaveBeenCalled()
|
|
1203
|
+
})
|
|
1204
|
+
}
|
|
1205
|
+
)
|
|
1206
|
+
|
|
1207
|
+
it.each([
|
|
1208
|
+
["whose stored session expired", "expired"],
|
|
1209
|
+
["whose session is signed in elsewhere", "elsewhere"],
|
|
1210
|
+
] as const)(
|
|
1211
|
+
"is rendered on the client when a useAuth() parent shows its status after hydrating, in a tab %s",
|
|
1212
|
+
async (_, kind) => {
|
|
1213
|
+
// The caller stays anonymous in such a tab, but the status differs from
|
|
1214
|
+
// the server's, so the parent moves on as on a signed-in reload.
|
|
1215
|
+
function StatusOnlyNav({ Late }: { Late: ComponentType }) {
|
|
1216
|
+
const { status } = useAuth()
|
|
1217
|
+
return (
|
|
1218
|
+
<nav data-status={status}>
|
|
1219
|
+
<Suspense fallback={<p>fallback</p>}>
|
|
1220
|
+
<Late />
|
|
1221
|
+
</Suspense>
|
|
1222
|
+
</nav>
|
|
1223
|
+
)
|
|
1224
|
+
}
|
|
1225
|
+
const empty: DehydratedState = { mutations: [], queries: [] }
|
|
1226
|
+
const { server, browser } = await steadyTab(kind)
|
|
1227
|
+
container.innerHTML = renderToString(
|
|
1228
|
+
tree(server, empty, StatusOnlyNav, Content)
|
|
1229
|
+
)
|
|
1230
|
+
const served = container.querySelector("article")
|
|
1231
|
+
expect(served?.textContent).toBe("from the server")
|
|
1232
|
+
|
|
1233
|
+
const { Late, deliver } = lateChunk(Content)
|
|
1234
|
+
const { recoverable, logged } = await hydrate(
|
|
1235
|
+
tree(browser, empty, StatusOnlyNav, Late)
|
|
1236
|
+
)
|
|
1237
|
+
|
|
1238
|
+
expect(container.querySelector("nav")?.dataset.status).toBe(
|
|
1239
|
+
ownStatus[kind]
|
|
1240
|
+
)
|
|
1241
|
+
expect(container.querySelector("p")?.textContent).toBe("fallback")
|
|
1242
|
+
expect(served?.isConnected).toBe(false)
|
|
1243
|
+
if (REACT_18) expect(recoverable.length).toBeGreaterThan(0)
|
|
1244
|
+
else expect(recoverable).toEqual([])
|
|
1245
|
+
expect(logged).not.toHaveBeenCalled()
|
|
1246
|
+
|
|
1247
|
+
await act(async () => deliver())
|
|
1248
|
+
await act(macrotask)
|
|
1249
|
+
expect(container.querySelector("article")?.textContent).toBe(
|
|
1250
|
+
"from the server"
|
|
1251
|
+
)
|
|
1252
|
+
}
|
|
1253
|
+
)
|
|
1254
|
+
|
|
1255
|
+
it("keeps the server's HTML when it is passed as children to the component that moves on", async () => {
|
|
1256
|
+
function Nav({ children }: { children: ReactNode }) {
|
|
1257
|
+
const client = useClient()
|
|
1258
|
+
return <nav data-caller={client.caller()}>{children}</nav>
|
|
1259
|
+
}
|
|
1260
|
+
const tree = (client: Client, Late: ComponentType) => (
|
|
1261
|
+
<ReactorProvider client={() => client}>
|
|
1262
|
+
<Nav>
|
|
1263
|
+
<Suspense fallback={<p>fallback</p>}>
|
|
1264
|
+
<Late />
|
|
1265
|
+
</Suspense>
|
|
1266
|
+
</Nav>
|
|
1267
|
+
</ReactorProvider>
|
|
1268
|
+
)
|
|
1269
|
+
const server = replica({ signedIn: false })
|
|
1270
|
+
container.innerHTML = renderToString(tree(server.client, Content))
|
|
1271
|
+
const served = container.querySelector("article")
|
|
1272
|
+
const { Late, deliver } = lateChunk(Content)
|
|
1273
|
+
const browser = replica({ seed: 7 })
|
|
1274
|
+
|
|
1275
|
+
const { recoverable } = await hydrate(tree(browser.client, Late))
|
|
1276
|
+
|
|
1277
|
+
expect(container.querySelector("nav")?.dataset.caller).toBe(
|
|
1278
|
+
browser.auth.getPrincipal()?.toText()
|
|
1279
|
+
)
|
|
1280
|
+
expect(container.querySelector("article")).toBe(served)
|
|
1281
|
+
expect(container.querySelector("p")).toBeNull()
|
|
1282
|
+
await act(async () => deliver())
|
|
1283
|
+
await act(macrotask)
|
|
1284
|
+
expect(container.querySelector("article")).toBe(served)
|
|
1285
|
+
expect(recoverable).toEqual([])
|
|
1286
|
+
})
|
|
1287
|
+
|
|
1288
|
+
it("keeps a streamed boundary pending when it is passed as children to the component that moves on", async () => {
|
|
1289
|
+
function Nav({ children }: { children: ReactNode }) {
|
|
1290
|
+
const client = useClient()
|
|
1291
|
+
return <nav data-caller={client.caller()}>{children}</nav>
|
|
1292
|
+
}
|
|
1293
|
+
const tree = (client: Client) => (
|
|
1294
|
+
<ReactorProvider client={() => client}>
|
|
1295
|
+
<Nav>
|
|
1296
|
+
<Suspense fallback={<p>fallback</p>}>
|
|
1297
|
+
<Content />
|
|
1298
|
+
</Suspense>
|
|
1299
|
+
</Nav>
|
|
1300
|
+
</ReactorProvider>
|
|
1301
|
+
)
|
|
1302
|
+
const server = replica({ signedIn: false })
|
|
1303
|
+
container.innerHTML = streamed(
|
|
1304
|
+
renderToString(tree(server.client)),
|
|
1305
|
+
"<article>from the server</article>"
|
|
1306
|
+
)
|
|
1307
|
+
await whileLoading(async () => {
|
|
1308
|
+
const browser = replica({ seed: 7 })
|
|
1309
|
+
const { recoverable } = await hydrate(tree(browser.client))
|
|
1310
|
+
await act(macrotask)
|
|
1311
|
+
|
|
1312
|
+
// The nav moved on to the session; the boundary still waits for the
|
|
1313
|
+
// stream.
|
|
1314
|
+
expect(container.querySelector("nav")?.dataset.caller).toBe(
|
|
1315
|
+
browser.auth.getPrincipal()?.toText()
|
|
1316
|
+
)
|
|
1317
|
+
expect(recoverable).toEqual([])
|
|
1318
|
+
expect(container.querySelector("template")).not.toBeNull()
|
|
1319
|
+
expect(container.querySelector("article")).toBeNull()
|
|
1320
|
+
})
|
|
1321
|
+
})
|
|
1322
|
+
|
|
1323
|
+
// The `em` assertion can only fail on React 18, which verify:peer-floors
|
|
1324
|
+
// runs: React 19 commits an unrelated transition even while another one is
|
|
1325
|
+
// held, so it passes there with or without a held transition, and
|
|
1326
|
+
// verify:faults (React 19 only) cannot verify it. Checked by hand on 18.0
|
|
1327
|
+
// with the first attempt's held settle transition put back.
|
|
1328
|
+
it("holds no transition: an unrelated one commits at once while the chunk loads", async () => {
|
|
1329
|
+
let bump!: () => void
|
|
1330
|
+
function Counter() {
|
|
1331
|
+
const [count, setCount] = useState(0)
|
|
1332
|
+
bump = () => startTransition(() => setCount(1))
|
|
1333
|
+
return <em>{count}</em>
|
|
1334
|
+
}
|
|
1335
|
+
const pageWith = (client: Client, Late: ComponentType) => (
|
|
1336
|
+
<ReactorProvider client={() => client}>
|
|
1337
|
+
<ActionNav Late={Late} />
|
|
1338
|
+
<Counter />
|
|
1339
|
+
</ReactorProvider>
|
|
1340
|
+
)
|
|
1341
|
+
const server = replica({ signedIn: false })
|
|
1342
|
+
container.innerHTML = renderToString(pageWith(server.client, Content))
|
|
1343
|
+
const { Late, deliver } = lateChunk(Content)
|
|
1344
|
+
const browser = replica({ seed: 7 })
|
|
1345
|
+
await hydrate(pageWith(browser.client, Late))
|
|
1346
|
+
|
|
1347
|
+
await act(async () => bump())
|
|
1348
|
+
await act(macrotask)
|
|
1349
|
+
expect(container.querySelector("em")?.textContent).toBe("1")
|
|
1350
|
+
|
|
1351
|
+
await act(async () => deliver())
|
|
1352
|
+
await act(macrotask)
|
|
1353
|
+
expect(container.querySelector("article")?.textContent).toBe(
|
|
1354
|
+
"from the server"
|
|
1355
|
+
)
|
|
1356
|
+
expect(container.querySelector("nav")?.dataset.caller).toBe(
|
|
1357
|
+
browser.auth.getPrincipal()?.toText()
|
|
1358
|
+
)
|
|
1359
|
+
})
|
|
1360
|
+
|
|
1361
|
+
it.each([
|
|
1362
|
+
["with the server's data", true],
|
|
1363
|
+
["without the server's data", false],
|
|
1364
|
+
] as const)(
|
|
1365
|
+
"lets a late child that reads the parent's key with useSuspenseQuery settle as the user: %s",
|
|
1366
|
+
async (_, dehydrated) => {
|
|
1367
|
+
const seen: (string | undefined)[] = []
|
|
1368
|
+
function Nav({ Late }: { Late: ComponentType }) {
|
|
1369
|
+
const client = useClient()
|
|
1370
|
+
const who = client.canister<Who>(WHO, { id: CANISTER })
|
|
1371
|
+
const read = useQuery(client.queryOptions(who, "whoami"))
|
|
1372
|
+
seen.push(read.data)
|
|
1373
|
+
return (
|
|
1374
|
+
<nav>
|
|
1375
|
+
<b>{read.isError ? "ERROR" : "nav"}</b>
|
|
1376
|
+
<Suspense fallback={<p>fallback</p>}>
|
|
1377
|
+
<Late />
|
|
1378
|
+
</Suspense>
|
|
1379
|
+
</nav>
|
|
1380
|
+
)
|
|
1381
|
+
}
|
|
1382
|
+
let clicks = 0
|
|
1383
|
+
function Child() {
|
|
1384
|
+
const client = useClient()
|
|
1385
|
+
const who = client.canister<Who>(WHO, { id: CANISTER })
|
|
1386
|
+
const read = useSuspenseQuery(client.queryOptions(who, "whoami"))
|
|
1387
|
+
return <article onClick={() => clicks++}>{read.data}</article>
|
|
1388
|
+
}
|
|
1389
|
+
const server = replica({ signedIn: false })
|
|
1390
|
+
const serverWho = server.client.canister<Who>(WHO, { id: CANISTER })
|
|
1391
|
+
await server.client.queryClient.prefetchQuery(
|
|
1392
|
+
server.client.queryOptions(serverWho, "whoami")
|
|
1393
|
+
)
|
|
1394
|
+
// The server rendered the read either way; it sent its state or not.
|
|
1395
|
+
const json = JSON.stringify(
|
|
1396
|
+
dehydrated
|
|
1397
|
+
? dehydrate(server.client.queryClient)
|
|
1398
|
+
: { mutations: [], queries: [] }
|
|
1399
|
+
)
|
|
1400
|
+
container.innerHTML = renderToString(
|
|
1401
|
+
tree(server.client, JSON.parse(json), Nav, Child)
|
|
1402
|
+
)
|
|
1403
|
+
const state = JSON.parse(json) as DehydratedState
|
|
1404
|
+
for (const query of state.queries) query.state.dataUpdatedAt = 1
|
|
1405
|
+
const { Late, deliver } = lateChunk(Child)
|
|
1406
|
+
const browser = replica({ seed: 7 })
|
|
1407
|
+
const user = browser.auth.getPrincipal()?.toText()
|
|
1408
|
+
const commits = watchCommits()
|
|
1409
|
+
try {
|
|
1410
|
+
await hydrate(tree(browser.client, state, Nav, Late))
|
|
1411
|
+
await act(async () => deliver())
|
|
1412
|
+
for (let tick = 0; tick < 5; tick++) await act(macrotask)
|
|
1413
|
+
|
|
1414
|
+
// Nothing waits for anything: the child renders as the user and its
|
|
1415
|
+
// handler runs.
|
|
1416
|
+
await waitFor(() =>
|
|
1417
|
+
expect(container.querySelector("article")?.textContent).toBe(
|
|
1418
|
+
`read by ${user}`
|
|
1419
|
+
)
|
|
1420
|
+
)
|
|
1421
|
+
act(() => container.querySelector("article")?.click())
|
|
1422
|
+
expect(clicks).toBe(1)
|
|
1423
|
+
expect(seen[seen.length - 1]).toBe(`read by ${user}`)
|
|
1424
|
+
const fetching = browser.client.queryClient
|
|
1425
|
+
.getQueryCache()
|
|
1426
|
+
.getAll()
|
|
1427
|
+
.filter((query) => query.state.fetchStatus !== "idle")
|
|
1428
|
+
expect(fetching).toEqual([])
|
|
1429
|
+
expect(
|
|
1430
|
+
commits.commits.filter((html) => html.includes("ERROR"))
|
|
1431
|
+
).toEqual([])
|
|
1432
|
+
expect(new Set(whoamiCallers(browser))).toEqual(new Set([user]))
|
|
1433
|
+
} finally {
|
|
1434
|
+
commits.stop()
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
)
|
|
1438
|
+
})
|
|
1439
|
+
|
|
1440
|
+
/**
|
|
1441
|
+
* L2: a read the hydrating render built for the anonymous caller may run once
|
|
1442
|
+
* while the user is current. It is cancelled before anything is sent, and the
|
|
1443
|
+
* component has moved on to the user's key by then.
|
|
1444
|
+
*/
|
|
1445
|
+
describe("a read built for the caller a hydrating render shows", () => {
|
|
1446
|
+
it("is cancelled when an effect of the hydrating render runs it, and sent as the user once the effect runs again", async () => {
|
|
1447
|
+
function Prefetch() {
|
|
1448
|
+
const client = useClient()
|
|
1449
|
+
const who = client.canister<Who>(WHO, { id: CANISTER })
|
|
1450
|
+
useEffect(() => {
|
|
1451
|
+
void client.queryClient.prefetchQuery(
|
|
1452
|
+
client.queryOptions(who, "whoami")
|
|
1453
|
+
)
|
|
1454
|
+
}, [client, who])
|
|
1455
|
+
return <main>page</main>
|
|
1456
|
+
}
|
|
1457
|
+
const tree = (client: Client) => (
|
|
1458
|
+
<ReactorProvider client={() => client}>
|
|
1459
|
+
<Prefetch />
|
|
1460
|
+
</ReactorProvider>
|
|
1461
|
+
)
|
|
1462
|
+
const server = replica({ signedIn: false })
|
|
1463
|
+
container.innerHTML = renderToString(tree(server.client))
|
|
1464
|
+
const browser = replica({ seed: 7 })
|
|
1465
|
+
const user = browser.auth.getPrincipal()?.toText()
|
|
1466
|
+
const errors = watchErrors(browser.client)
|
|
1467
|
+
|
|
1468
|
+
const { recoverable } = await hydrate(tree(browser.client))
|
|
1469
|
+
await waitFor(() => expect(whoamiCallers(browser)).toEqual([user]))
|
|
1470
|
+
errors.stop()
|
|
1471
|
+
|
|
1472
|
+
// A listener of the client's QueryCache sees it, as kind "cancelled",
|
|
1473
|
+
// and the README's filter drops it, so such a listener reports nothing.
|
|
1474
|
+
expect(recoverable).toEqual([])
|
|
1475
|
+
expect(errors.errors).toHaveLength(1)
|
|
1476
|
+
expect(errors.errors[0]).toMatchObject({
|
|
1477
|
+
kind: "cancelled",
|
|
1478
|
+
code: "caller_changed",
|
|
1479
|
+
})
|
|
1480
|
+
expect(
|
|
1481
|
+
errors.errors.filter(
|
|
1482
|
+
(error) => !(isReactorError(error) && error.kind === "cancelled")
|
|
1483
|
+
)
|
|
1484
|
+
).toEqual([])
|
|
1485
|
+
})
|
|
1486
|
+
|
|
1487
|
+
it("is never shown as an error by a component that hydrated", async () => {
|
|
1488
|
+
// The parent's useSuspenseQuery read is stale, so mounting it fetches it
|
|
1489
|
+
// again while the user is current.
|
|
1490
|
+
function SuspenseReader() {
|
|
1491
|
+
const client = useClient()
|
|
1492
|
+
const who = client.canister<Who>(WHO, { id: CANISTER })
|
|
1493
|
+
const read = useSuspenseQuery(client.queryOptions(who, "whoami"))
|
|
1494
|
+
return <main>{read.isError ? "ERROR" : read.data}</main>
|
|
1495
|
+
}
|
|
1496
|
+
const server = replica({ signedIn: false })
|
|
1497
|
+
const serverWho = server.client.canister<Who>(WHO, { id: CANISTER })
|
|
1498
|
+
await server.client.queryClient.prefetchQuery(
|
|
1499
|
+
server.client.queryOptions(serverWho, "whoami")
|
|
1500
|
+
)
|
|
1501
|
+
const json = JSON.stringify(dehydrate(server.client.queryClient))
|
|
1502
|
+
container.innerHTML = renderToString(
|
|
1503
|
+
page(server.client, JSON.parse(json), SuspenseReader)
|
|
1504
|
+
)
|
|
1505
|
+
const served = container.querySelector("main")
|
|
1506
|
+
const state = JSON.parse(json) as DehydratedState
|
|
1507
|
+
for (const query of state.queries) query.state.dataUpdatedAt = 1
|
|
1508
|
+
const browser = replica({ seed: 7 })
|
|
1509
|
+
const user = browser.auth.getPrincipal()?.toText()
|
|
1510
|
+
const commits = watchCommits()
|
|
1511
|
+
const errors = watchErrors(browser.client)
|
|
1512
|
+
|
|
1513
|
+
const { recoverable, logged } = await hydrate(
|
|
1514
|
+
<StrictMode>{page(browser.client, state, SuspenseReader)}</StrictMode>
|
|
1515
|
+
)
|
|
1516
|
+
await waitFor(() =>
|
|
1517
|
+
expect(container.querySelector("main")?.textContent).toBe(
|
|
1518
|
+
`read by ${user}`
|
|
1519
|
+
)
|
|
1520
|
+
)
|
|
1521
|
+
commits.stop()
|
|
1522
|
+
errors.stop()
|
|
1523
|
+
|
|
1524
|
+
expect(recoverable).toEqual([])
|
|
1525
|
+
expect(logged).not.toHaveBeenCalled()
|
|
1526
|
+
expect(container.querySelector("main")).toBe(served)
|
|
1527
|
+
expect(commits.commits.filter((html) => html.includes("ERROR"))).toEqual([])
|
|
1528
|
+
expect(whoamiCallers(browser)).toEqual([user])
|
|
1529
|
+
for (const error of errors.errors) {
|
|
1530
|
+
expect(error).toMatchObject({ kind: "cancelled", code: "caller_changed" })
|
|
1531
|
+
}
|
|
1532
|
+
})
|
|
1533
|
+
})
|
|
1534
|
+
|
|
1535
|
+
/**
|
|
1536
|
+
* L2, later: a read the hydrating render built is cancelled while the user is
|
|
1537
|
+
* current, and a component that hydrated may still be watching its key when
|
|
1538
|
+
* that happens (React hides it, mounted, while the move suspends). The read
|
|
1539
|
+
* leaves the key as it found it, so once the caller is anonymous again (a
|
|
1540
|
+
* sign-out, a session that expires) the key shows the server's data and is
|
|
1541
|
+
* read again as nobody, never the cancelled read as an error.
|
|
1542
|
+
*/
|
|
1543
|
+
describe("a key whose read the hydrating render built, once the caller is anonymous again", () => {
|
|
1544
|
+
/** A second query, so that a sibling can suspend on a key of its own. */
|
|
1545
|
+
const PAIR = c.service({
|
|
1546
|
+
whoami: c.func([], [c.text], "query"),
|
|
1547
|
+
other: c.func([], [c.text], "query"),
|
|
1548
|
+
})
|
|
1549
|
+
type Pair = { whoami: () => Promise<string>; other: () => Promise<string> }
|
|
1550
|
+
function pairReplica(options: { seed?: number; signedIn?: boolean } = {}) {
|
|
1551
|
+
const test = createTestClient({
|
|
1552
|
+
identity: options.seed ?? 7,
|
|
1553
|
+
signedIn: options.signedIn ?? true,
|
|
1554
|
+
})
|
|
1555
|
+
test.mock<Pair>(PAIR, CANISTER, {
|
|
1556
|
+
whoami: ({ caller }) => `read by ${caller}`,
|
|
1557
|
+
other: ({ caller }) => `other by ${caller}`,
|
|
1558
|
+
})
|
|
1559
|
+
tests.push(test)
|
|
1560
|
+
return test
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
/** The server's state of every read it prefetched, made stale. */
|
|
1564
|
+
async function prefetched(
|
|
1565
|
+
server: Replica | ReturnType<typeof pairReplica>,
|
|
1566
|
+
methods: readonly ("whoami" | "other")[]
|
|
1567
|
+
) {
|
|
1568
|
+
const who = server.client.canister<Pair>(PAIR, { id: CANISTER })
|
|
1569
|
+
for (const method of methods) {
|
|
1570
|
+
await server.client.queryClient.prefetchQuery(
|
|
1571
|
+
server.client.queryOptions(who, method)
|
|
1572
|
+
)
|
|
1573
|
+
}
|
|
1574
|
+
const json = JSON.stringify(dehydrate(server.client.queryClient))
|
|
1575
|
+
const stale = JSON.parse(json) as DehydratedState
|
|
1576
|
+
for (const query of stale.queries) query.state.dataUpdatedAt = 1
|
|
1577
|
+
return { fresh: JSON.parse(json) as DehydratedState, stale }
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
const leave = {
|
|
1581
|
+
"a sign-out": async (test: Replica) => {
|
|
1582
|
+
await act(async () => {
|
|
1583
|
+
await test.auth.signOut()
|
|
1584
|
+
})
|
|
1585
|
+
},
|
|
1586
|
+
"an expired session": async (test: Replica) => {
|
|
1587
|
+
await act(async () => {
|
|
1588
|
+
test.auth.expire()
|
|
1589
|
+
await macrotask()
|
|
1590
|
+
})
|
|
1591
|
+
},
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
const anonymousWhoami = (test: Replica) =>
|
|
1595
|
+
test.client.queryClient
|
|
1596
|
+
.getQueryCache()
|
|
1597
|
+
.getAll()
|
|
1598
|
+
.find(
|
|
1599
|
+
(query) =>
|
|
1600
|
+
query.queryKey.includes(ANONYMOUS) &&
|
|
1601
|
+
query.queryKey.includes("whoami")
|
|
1602
|
+
)
|
|
1603
|
+
|
|
1604
|
+
it.each([
|
|
1605
|
+
["a sign-out", false],
|
|
1606
|
+
["a sign-out", true],
|
|
1607
|
+
["an expired session", false],
|
|
1608
|
+
["an expired session", true],
|
|
1609
|
+
] as const)(
|
|
1610
|
+
"is shown by a useSuspenseQuery reader with its data after %s, never as an error (StrictMode: %s)",
|
|
1611
|
+
async (how, strict) => {
|
|
1612
|
+
function SuspenseReader() {
|
|
1613
|
+
const client = useClient()
|
|
1614
|
+
const who = client.canister<Pair>(PAIR, { id: CANISTER })
|
|
1615
|
+
const read = useSuspenseQuery(client.queryOptions(who, "whoami"))
|
|
1616
|
+
return <main>{read.isError ? "ERROR" : read.data}</main>
|
|
1617
|
+
}
|
|
1618
|
+
const wrap = (element: ReactElement) =>
|
|
1619
|
+
strict ? <StrictMode>{element}</StrictMode> : element
|
|
1620
|
+
const server = pairReplica({ signedIn: false })
|
|
1621
|
+
const { fresh, stale } = await prefetched(server, ["whoami"])
|
|
1622
|
+
container.innerHTML = renderToString(
|
|
1623
|
+
wrap(page(server.client, fresh, SuspenseReader))
|
|
1624
|
+
)
|
|
1625
|
+
const browser = pairReplica({ seed: 7 })
|
|
1626
|
+
const user = browser.auth.getPrincipal()?.toText()
|
|
1627
|
+
const commits = watchCommits()
|
|
1628
|
+
try {
|
|
1629
|
+
await hydrate(wrap(page(browser.client, stale, SuspenseReader)))
|
|
1630
|
+
await waitFor(() =>
|
|
1631
|
+
expect(container.querySelector("main")?.textContent).toBe(
|
|
1632
|
+
`read by ${user}`
|
|
1633
|
+
)
|
|
1634
|
+
)
|
|
1635
|
+
expect(whoamiCallers(browser)).toEqual([user])
|
|
1636
|
+
|
|
1637
|
+
await leave[how](browser)
|
|
1638
|
+
await waitFor(() =>
|
|
1639
|
+
expect(whoamiCallers(browser)).toEqual([user, ANONYMOUS])
|
|
1640
|
+
)
|
|
1641
|
+
for (let tick = 0; tick < 3; tick++) await act(macrotask)
|
|
1642
|
+
|
|
1643
|
+
expect(container.querySelector("main")?.textContent).toBe(
|
|
1644
|
+
`read by ${ANONYMOUS}`
|
|
1645
|
+
)
|
|
1646
|
+
expect(
|
|
1647
|
+
commits.commits.filter((html) => html.includes("ERROR"))
|
|
1648
|
+
).toEqual([])
|
|
1649
|
+
expect(anonymousWhoami(browser)?.state).toMatchObject({
|
|
1650
|
+
status: "success",
|
|
1651
|
+
fetchStatus: "idle",
|
|
1652
|
+
})
|
|
1653
|
+
} finally {
|
|
1654
|
+
commits.stop()
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
)
|
|
1658
|
+
|
|
1659
|
+
// The rows without data guard an invariant: a reader with no data of the
|
|
1660
|
+
// server's has nothing a cancelled read could leave behind.
|
|
1661
|
+
it.each([
|
|
1662
|
+
["with the server's data", false, true],
|
|
1663
|
+
["with the server's data", true, true],
|
|
1664
|
+
["without data", false, false],
|
|
1665
|
+
["without data", true, false],
|
|
1666
|
+
] as const)(
|
|
1667
|
+
"is shown by a useQuery reader %s whose sibling's suspense read suspends the move, never as an error (StrictMode: %s)",
|
|
1668
|
+
async (_, strict, withData) => {
|
|
1669
|
+
function Plain() {
|
|
1670
|
+
const client = useClient()
|
|
1671
|
+
const who = client.canister<Pair>(PAIR, { id: CANISTER })
|
|
1672
|
+
const read = useQuery(client.queryOptions(who, "whoami"))
|
|
1673
|
+
return <main>{read.isError ? "ERROR" : (read.data ?? "loading")}</main>
|
|
1674
|
+
}
|
|
1675
|
+
function Sibling() {
|
|
1676
|
+
const client = useClient()
|
|
1677
|
+
const who = client.canister<Pair>(PAIR, { id: CANISTER })
|
|
1678
|
+
const read = useSuspenseQuery(client.queryOptions(who, "other"))
|
|
1679
|
+
return <aside>{read.data}</aside>
|
|
1680
|
+
}
|
|
1681
|
+
const tree = (client: Client, state: DehydratedState) => {
|
|
1682
|
+
const element = (
|
|
1683
|
+
<ReactorProvider client={() => client}>
|
|
1684
|
+
<HydrationBoundary state={state}>
|
|
1685
|
+
<Suspense fallback={<p>loading</p>}>
|
|
1686
|
+
<Plain />
|
|
1687
|
+
<Sibling />
|
|
1688
|
+
</Suspense>
|
|
1689
|
+
</HydrationBoundary>
|
|
1690
|
+
</ReactorProvider>
|
|
1691
|
+
)
|
|
1692
|
+
return strict ? <StrictMode>{element}</StrictMode> : element
|
|
1693
|
+
}
|
|
1694
|
+
const server = pairReplica({ signedIn: false })
|
|
1695
|
+
const { fresh, stale } = await prefetched(
|
|
1696
|
+
server,
|
|
1697
|
+
withData ? ["whoami", "other"] : ["other"]
|
|
1698
|
+
)
|
|
1699
|
+
// Only the reader's data is stale. The sibling's is fresh, and the move
|
|
1700
|
+
// suspends on the user's key for it, which has nothing yet.
|
|
1701
|
+
const sibling = fresh.queries.find((q) => q.queryKey.includes("other"))
|
|
1702
|
+
for (const query of stale.queries) {
|
|
1703
|
+
if (query.queryKey.includes("other") && sibling !== undefined) {
|
|
1704
|
+
query.state.dataUpdatedAt = sibling.state.dataUpdatedAt
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
container.innerHTML = renderToString(tree(server.client, fresh))
|
|
1708
|
+
const browser = pairReplica({ seed: 7 })
|
|
1709
|
+
const user = browser.auth.getPrincipal()?.toText()
|
|
1710
|
+
const commits = watchCommits()
|
|
1711
|
+
try {
|
|
1712
|
+
await hydrate(tree(browser.client, stale))
|
|
1713
|
+
await waitFor(() =>
|
|
1714
|
+
expect(container.querySelector("main")?.textContent).toBe(
|
|
1715
|
+
`read by ${user}`
|
|
1716
|
+
)
|
|
1717
|
+
)
|
|
1718
|
+
for (let tick = 0; tick < 3; tick++) await act(macrotask)
|
|
1719
|
+
// StrictMode mounts the reader twice, and each mount may read.
|
|
1720
|
+
expect(new Set(whoamiCallers(browser))).toEqual(new Set([user]))
|
|
1721
|
+
const sent = whoamiCallers(browser).length
|
|
1722
|
+
|
|
1723
|
+
await leave["a sign-out"](browser)
|
|
1724
|
+
await waitFor(() =>
|
|
1725
|
+
expect(whoamiCallers(browser).slice(sent)).toEqual([ANONYMOUS])
|
|
1726
|
+
)
|
|
1727
|
+
for (let tick = 0; tick < 3; tick++) await act(macrotask)
|
|
1728
|
+
|
|
1729
|
+
expect(container.querySelector("main")?.textContent).toBe(
|
|
1730
|
+
`read by ${ANONYMOUS}`
|
|
1731
|
+
)
|
|
1732
|
+
expect(
|
|
1733
|
+
commits.commits.filter((html) => html.includes("ERROR"))
|
|
1734
|
+
).toEqual([])
|
|
1735
|
+
} finally {
|
|
1736
|
+
commits.stop()
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
)
|
|
1740
|
+
|
|
1741
|
+
it("is shown with its data by a reader mounted after a sign-out, when an effect of the hydrating render fetched it", async () => {
|
|
1742
|
+
let showReader!: () => void
|
|
1743
|
+
function Prefetch() {
|
|
1744
|
+
const client = useClient()
|
|
1745
|
+
const who = client.canister<Pair>(PAIR, { id: CANISTER })
|
|
1746
|
+
const [shown, setShown] = useState(false)
|
|
1747
|
+
showReader = () => setShown(true)
|
|
1748
|
+
useEffect(() => {
|
|
1749
|
+
void client.queryClient.prefetchQuery(
|
|
1750
|
+
client.queryOptions(who, "whoami")
|
|
1751
|
+
)
|
|
1752
|
+
}, [client, who])
|
|
1753
|
+
return shown ? <Reader /> : <main>page</main>
|
|
1754
|
+
}
|
|
1755
|
+
const tree = (client: Client, state: DehydratedState) => (
|
|
1756
|
+
<ReactorProvider client={() => client}>
|
|
1757
|
+
<HydrationBoundary state={state}>
|
|
1758
|
+
<Prefetch />
|
|
1759
|
+
</HydrationBoundary>
|
|
1760
|
+
</ReactorProvider>
|
|
1761
|
+
)
|
|
1762
|
+
const server = pairReplica({ signedIn: false })
|
|
1763
|
+
const { fresh, stale } = await prefetched(server, ["whoami"])
|
|
1764
|
+
container.innerHTML = renderToString(tree(server.client, fresh))
|
|
1765
|
+
const browser = pairReplica({ seed: 7 })
|
|
1766
|
+
const user = browser.auth.getPrincipal()?.toText()
|
|
1767
|
+
await hydrate(tree(browser.client, stale))
|
|
1768
|
+
await waitFor(() => expect(whoamiCallers(browser)).toEqual([user]))
|
|
1769
|
+
for (let tick = 0; tick < 3; tick++) await act(macrotask)
|
|
1770
|
+
|
|
1771
|
+
expect(whoamiCallers(browser)).toEqual([user])
|
|
1772
|
+
await leave["a sign-out"](browser)
|
|
1773
|
+
const commits = watchCommits()
|
|
1774
|
+
try {
|
|
1775
|
+
act(() => showReader())
|
|
1776
|
+
await waitFor(() => expect(whoamiCallers(browser)).toContain(ANONYMOUS))
|
|
1777
|
+
for (let tick = 0; tick < 3; tick++) await act(macrotask)
|
|
1778
|
+
expect(container.querySelector("main")?.textContent).toBe(
|
|
1779
|
+
`read by ${ANONYMOUS}`
|
|
1780
|
+
)
|
|
1781
|
+
expect(commits.commits.filter((html) => html.includes("ERROR"))).toEqual(
|
|
1782
|
+
[]
|
|
1783
|
+
)
|
|
1784
|
+
} finally {
|
|
1785
|
+
commits.stop()
|
|
1786
|
+
}
|
|
1787
|
+
})
|
|
1788
|
+
|
|
1789
|
+
it("hands an effect that awaits the hydrating render's fetch the data the key had, not a rejection", async () => {
|
|
1790
|
+
const settled: string[] = []
|
|
1791
|
+
function Fetch() {
|
|
1792
|
+
const client = useClient()
|
|
1793
|
+
const who = client.canister<Pair>(PAIR, { id: CANISTER })
|
|
1794
|
+
useEffect(() => {
|
|
1795
|
+
client.queryClient.fetchQuery(client.queryOptions(who, "whoami")).then(
|
|
1796
|
+
(data) => settled.push(`data ${data}`),
|
|
1797
|
+
(error: unknown) =>
|
|
1798
|
+
settled.push(`error ${(error as { code?: string }).code}`)
|
|
1799
|
+
)
|
|
1800
|
+
}, [client, who])
|
|
1801
|
+
return <main>page</main>
|
|
1802
|
+
}
|
|
1803
|
+
const tree = (client: Client, state: DehydratedState) => (
|
|
1804
|
+
<ReactorProvider client={() => client}>
|
|
1805
|
+
<HydrationBoundary state={state}>
|
|
1806
|
+
<Fetch />
|
|
1807
|
+
</HydrationBoundary>
|
|
1808
|
+
</ReactorProvider>
|
|
1809
|
+
)
|
|
1810
|
+
const server = pairReplica({ signedIn: false })
|
|
1811
|
+
const { fresh, stale } = await prefetched(server, ["whoami"])
|
|
1812
|
+
container.innerHTML = renderToString(tree(server.client, fresh))
|
|
1813
|
+
const browser = pairReplica({ seed: 7 })
|
|
1814
|
+
const user = browser.auth.getPrincipal()?.toText()
|
|
1815
|
+
const errors = watchErrors(browser.client)
|
|
1816
|
+
await hydrate(tree(browser.client, stale))
|
|
1817
|
+
await waitFor(() => expect(settled).toHaveLength(2))
|
|
1818
|
+
errors.stop()
|
|
1819
|
+
|
|
1820
|
+
// The view's fetch ran first and was cancelled before anything was sent;
|
|
1821
|
+
// the client's own, after the move, was sent as the user.
|
|
1822
|
+
expect(settled).toEqual([
|
|
1823
|
+
`data read by ${ANONYMOUS}`,
|
|
1824
|
+
`data read by ${user}`,
|
|
1825
|
+
])
|
|
1826
|
+
expect(whoamiCallers(browser)).toEqual([user])
|
|
1827
|
+
expect(errors.errors).toEqual([])
|
|
1828
|
+
expect(anonymousWhoami(browser)?.state).toMatchObject({
|
|
1829
|
+
status: "success",
|
|
1830
|
+
fetchStatus: "idle",
|
|
1831
|
+
})
|
|
1832
|
+
})
|
|
1833
|
+
})
|
|
1834
|
+
|
|
1835
|
+
describe("a suspense read the server did not dehydrate", () => {
|
|
1836
|
+
it("is cancelled while the user is current, and its boundary renders on the client as the user, never showing an error", async () => {
|
|
1837
|
+
function Read() {
|
|
1838
|
+
const client = useClient()
|
|
1839
|
+
const who = client.canister<Who>(WHO, { id: CANISTER })
|
|
1840
|
+
return (
|
|
1841
|
+
<main>{useSuspenseQuery(client.queryOptions(who, "whoami")).data}</main>
|
|
1842
|
+
)
|
|
1843
|
+
}
|
|
1844
|
+
class Catch extends Component<
|
|
1845
|
+
{ children: ReactNode },
|
|
1846
|
+
{ failed: boolean }
|
|
1847
|
+
> {
|
|
1848
|
+
state = { failed: false }
|
|
1849
|
+
static getDerivedStateFromError() {
|
|
1850
|
+
return { failed: true }
|
|
1851
|
+
}
|
|
1852
|
+
render() {
|
|
1853
|
+
return this.state.failed ? <b>ERROR</b> : this.props.children
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
const tree = (client: Client) => (
|
|
1857
|
+
<ReactorProvider client={() => client}>
|
|
1858
|
+
<Catch>
|
|
1859
|
+
<Suspense fallback={<p>loading</p>}>
|
|
1860
|
+
<Read />
|
|
1861
|
+
</Suspense>
|
|
1862
|
+
</Catch>
|
|
1863
|
+
</ReactorProvider>
|
|
1864
|
+
)
|
|
1865
|
+
// The server read the data and rendered it, but sent no state for it.
|
|
1866
|
+
const server = replica({ signedIn: false })
|
|
1867
|
+
const serverWho = server.client.canister<Who>(WHO, { id: CANISTER })
|
|
1868
|
+
await server.client.queryClient.prefetchQuery(
|
|
1869
|
+
server.client.queryOptions(serverWho, "whoami")
|
|
1870
|
+
)
|
|
1871
|
+
container.innerHTML = renderToString(tree(server.client))
|
|
1872
|
+
expect(container.querySelector("main")?.textContent).toBe(
|
|
1873
|
+
`read by ${ANONYMOUS}`
|
|
1874
|
+
)
|
|
1875
|
+
|
|
1876
|
+
const browser = replica({ seed: 7 })
|
|
1877
|
+
const user = browser.auth.getPrincipal()?.toText()
|
|
1878
|
+
const commits = watchCommits()
|
|
1879
|
+
const { recoverable, reported } = await hydrate(tree(browser.client))
|
|
1880
|
+
await waitFor(() =>
|
|
1881
|
+
expect(container.querySelector("main")?.textContent).toBe(
|
|
1882
|
+
`read by ${user}`
|
|
1883
|
+
)
|
|
1884
|
+
)
|
|
1885
|
+
commits.stop()
|
|
1886
|
+
|
|
1887
|
+
// React reports that it rendered the boundary on the client, with the
|
|
1888
|
+
// cancelled read as the reason: the error itself on React 18, its
|
|
1889
|
+
// `cause` on React 19. An `onRecoverableError` that forwards errors
|
|
1890
|
+
// should ignore kind "cancelled".
|
|
1891
|
+
expect(recoverable.length).toBeGreaterThan(0)
|
|
1892
|
+
expect(
|
|
1893
|
+
reported.map((error) =>
|
|
1894
|
+
REACT_18 ? error : (error as { cause?: unknown }).cause
|
|
1895
|
+
)
|
|
1896
|
+
).toContainEqual(
|
|
1897
|
+
expect.objectContaining({ kind: "cancelled", code: "caller_changed" })
|
|
1898
|
+
)
|
|
1899
|
+
expect(whoamiCallers(browser)).toEqual([user])
|
|
1900
|
+
|
|
1901
|
+
// Once nobody is current, the key the hydrating render read is read
|
|
1902
|
+
// again as nobody, and the cancelled read is not shown either.
|
|
1903
|
+
const after = watchCommits()
|
|
1904
|
+
await act(async () => {
|
|
1905
|
+
await browser.auth.signOut()
|
|
1906
|
+
})
|
|
1907
|
+
await waitFor(() =>
|
|
1908
|
+
expect(whoamiCallers(browser)).toEqual([user, ANONYMOUS])
|
|
1909
|
+
)
|
|
1910
|
+
await waitFor(() =>
|
|
1911
|
+
expect(container.querySelector("main")?.textContent).toBe(
|
|
1912
|
+
`read by ${ANONYMOUS}`
|
|
1913
|
+
)
|
|
1914
|
+
)
|
|
1915
|
+
after.stop()
|
|
1916
|
+
expect(
|
|
1917
|
+
[...commits.commits, ...after.commits].filter((html) =>
|
|
1918
|
+
html.includes("ERROR")
|
|
1919
|
+
)
|
|
1920
|
+
).toEqual([])
|
|
1921
|
+
})
|
|
1922
|
+
})
|
|
1923
|
+
|
|
1924
|
+
/**
|
|
1925
|
+
* L3: the move to the session is a synchronous update, and a
|
|
1926
|
+
* `useSuspenseQuery` read with none of the user's data yet suspends it. With a
|
|
1927
|
+
* Suspense boundary above the read, the boundary shows its fallback until the
|
|
1928
|
+
* data arrives, and the page commits its other updates meanwhile. With none,
|
|
1929
|
+
* React 18 refuses a synchronous update that suspends and unmounts the root;
|
|
1930
|
+
* React 19 keeps the server's HTML but commits none of this page's updates
|
|
1931
|
+
* until the data arrives: a click, a state change from outside an event, and
|
|
1932
|
+
* a transition that renders the read again all wait (measured on 19.3: only a
|
|
1933
|
+
* transition that leaves the read alone commits). Accepted: React expects a
|
|
1934
|
+
* boundary above a component that suspends.
|
|
1935
|
+
*/
|
|
1936
|
+
describe("a useSuspenseQuery read on a signed-in reload", () => {
|
|
1937
|
+
function SuspenseReader() {
|
|
1938
|
+
const client = useClient()
|
|
1939
|
+
const who = client.canister<Who>(WHO, { id: CANISTER })
|
|
1940
|
+
const read = useSuspenseQuery(client.queryOptions(who, "whoami"))
|
|
1941
|
+
return <main>{read.data}</main>
|
|
1942
|
+
}
|
|
1943
|
+
/** {@link Counter}'s setter, for an update from outside an event. */
|
|
1944
|
+
let setCount: ((update: (count: number) => number) => void) | undefined
|
|
1945
|
+
/**
|
|
1946
|
+
* A count, which a click on its first button adds one to and a click on its
|
|
1947
|
+
* second adds one to in a transition, above the read: each update of it
|
|
1948
|
+
* renders the read again.
|
|
1949
|
+
*/
|
|
1950
|
+
function Counter({ boundary }: { boundary: boolean }) {
|
|
1951
|
+
const [count, set] = useState(0)
|
|
1952
|
+
setCount = set
|
|
1953
|
+
return (
|
|
1954
|
+
<>
|
|
1955
|
+
<nav>
|
|
1956
|
+
<button onClick={() => set((n) => n + 1)}>
|
|
1957
|
+
<em>{count}</em>
|
|
1958
|
+
</button>
|
|
1959
|
+
<button onClick={() => startTransition(() => set((n) => n + 1))}>
|
|
1960
|
+
later
|
|
1961
|
+
</button>
|
|
1962
|
+
</nav>
|
|
1963
|
+
{boundary ? (
|
|
1964
|
+
<Suspense fallback={<p>fallback</p>}>
|
|
1965
|
+
<SuspenseReader />
|
|
1966
|
+
</Suspense>
|
|
1967
|
+
) : (
|
|
1968
|
+
<SuspenseReader />
|
|
1969
|
+
)}
|
|
1970
|
+
</>
|
|
1971
|
+
)
|
|
1972
|
+
}
|
|
1973
|
+
const tree = (client: Client, state: DehydratedState, boundary: boolean) => (
|
|
1974
|
+
<ReactorProvider client={() => client}>
|
|
1975
|
+
<HydrationBoundary state={state}>
|
|
1976
|
+
<header>top</header>
|
|
1977
|
+
<Counter boundary={boundary} />
|
|
1978
|
+
</HydrationBoundary>
|
|
1979
|
+
</ReactorProvider>
|
|
1980
|
+
)
|
|
1981
|
+
const count = () => container.querySelector("em")?.textContent
|
|
1982
|
+
/**
|
|
1983
|
+
* The server's page, with its read prefetched and dehydrated as nobody, and
|
|
1984
|
+
* the browser's replica, whose reads wait until the test calls `release`.
|
|
1985
|
+
*/
|
|
1986
|
+
async function served(boundary: boolean) {
|
|
1987
|
+
const server = replica({ signedIn: false })
|
|
1988
|
+
const serverWho = server.client.canister<Who>(WHO, { id: CANISTER })
|
|
1989
|
+
await server.client.queryClient.prefetchQuery(
|
|
1990
|
+
server.client.queryOptions(serverWho, "whoami")
|
|
1991
|
+
)
|
|
1992
|
+
const json = JSON.stringify(dehydrate(server.client.queryClient))
|
|
1993
|
+
container.innerHTML = renderToString(
|
|
1994
|
+
tree(server.client, JSON.parse(json), boundary)
|
|
1995
|
+
)
|
|
1996
|
+
expect(container.querySelector("main")?.textContent).toBe(
|
|
1997
|
+
`read by ${ANONYMOUS}`
|
|
1998
|
+
)
|
|
1999
|
+
expect(count()).toBe("0")
|
|
2000
|
+
const browser = replica({ seed: 7 })
|
|
2001
|
+
let release!: () => void
|
|
2002
|
+
const held = new Promise<void>((resolve) => (release = resolve))
|
|
2003
|
+
browser.mock<Who>(WHO, CANISTER, {
|
|
2004
|
+
whoami: async ({ caller }) => {
|
|
2005
|
+
await held
|
|
2006
|
+
return `read by ${caller}`
|
|
2007
|
+
},
|
|
2008
|
+
})
|
|
2009
|
+
return {
|
|
2010
|
+
state: JSON.parse(json) as DehydratedState,
|
|
2011
|
+
main: container.querySelector("main"),
|
|
2012
|
+
browser,
|
|
2013
|
+
user: browser.auth.getPrincipal()?.toText(),
|
|
2014
|
+
release,
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
/**
|
|
2018
|
+
* Adds one to the count three times, as a click, a transition and a state
|
|
2019
|
+
* change from outside an event, and returns the count shown after each.
|
|
2020
|
+
*/
|
|
2021
|
+
async function update() {
|
|
2022
|
+
const [click, later] = container.querySelectorAll("button")
|
|
2023
|
+
const shown: (string | undefined)[] = []
|
|
2024
|
+
for (const run of [
|
|
2025
|
+
() => click.click(),
|
|
2026
|
+
() => later.click(),
|
|
2027
|
+
() => setCount?.((n) => n + 1),
|
|
2028
|
+
]) {
|
|
2029
|
+
await act(async () => run())
|
|
2030
|
+
await act(macrotask)
|
|
2031
|
+
shown.push(count())
|
|
2032
|
+
}
|
|
2033
|
+
return shown
|
|
2034
|
+
}
|
|
2035
|
+
|
|
2036
|
+
it("shows the fallback of a Suspense boundary above it until the user's data arrives, then that data, and commits the page's updates meanwhile", async () => {
|
|
2037
|
+
const { state, main, browser, user, release } = await served(true)
|
|
2038
|
+
const commits = watchCommits()
|
|
2039
|
+
|
|
2040
|
+
const { recoverable, logged } = await hydrate(
|
|
2041
|
+
tree(browser.client, state, true)
|
|
2042
|
+
)
|
|
2043
|
+
expect(container.querySelector("p")?.textContent).toBe("fallback")
|
|
2044
|
+
expect(await update()).toEqual(["1", "2", "3"])
|
|
2045
|
+
expect(container.querySelector("p")?.textContent).toBe("fallback")
|
|
2046
|
+
release()
|
|
2047
|
+
await waitFor(() =>
|
|
2048
|
+
expect(container.querySelector("main")?.textContent).toBe(
|
|
2049
|
+
`read by ${user}`
|
|
2050
|
+
)
|
|
2051
|
+
)
|
|
2052
|
+
commits.stop()
|
|
2053
|
+
|
|
2054
|
+
expect(recoverable).toEqual([])
|
|
2055
|
+
expect(logged).not.toHaveBeenCalled()
|
|
2056
|
+
// The move hid the server's content behind the fallback, then showed the
|
|
2057
|
+
// user's data in the same node.
|
|
2058
|
+
expect(commits.commits[0]).toContain("<p>fallback</p>")
|
|
2059
|
+
expect(commits.commits[0]).toContain(`read by ${ANONYMOUS}`)
|
|
2060
|
+
expect(commits.commits[commits.commits.length - 1]).not.toContain(
|
|
2061
|
+
"fallback"
|
|
2062
|
+
)
|
|
2063
|
+
expect(container.querySelector("header")?.textContent).toBe("top")
|
|
2064
|
+
expect(count()).toBe("3")
|
|
2065
|
+
expect(container.querySelector("main")).toBe(main)
|
|
2066
|
+
expect(whoamiCallers(browser)).toEqual([user])
|
|
2067
|
+
})
|
|
2068
|
+
|
|
2069
|
+
it(
|
|
2070
|
+
REACT_18
|
|
2071
|
+
? "fails to render with no Suspense boundary above it on React 18: React unmounts the root"
|
|
2072
|
+
: "keeps the server's HTML with no Suspense boundary above it on React 19, and commits none of the page's updates until the user's data arrives",
|
|
2073
|
+
async () => {
|
|
2074
|
+
const { state, main, browser, user, release } = await served(false)
|
|
2075
|
+
const commits = watchCommits()
|
|
2076
|
+
const recoverable: unknown[] = []
|
|
2077
|
+
const logged = vi.spyOn(console, "error").mockImplementation(() => {})
|
|
2078
|
+
let thrown: unknown
|
|
2079
|
+
try {
|
|
2080
|
+
await act(async () => {
|
|
2081
|
+
root = hydrateRoot(container, tree(browser.client, state, false), {
|
|
2082
|
+
onRecoverableError: (error) => recoverable.push(error),
|
|
2083
|
+
})
|
|
2084
|
+
})
|
|
2085
|
+
await act(macrotask)
|
|
2086
|
+
} catch (error) {
|
|
2087
|
+
thrown = error
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
if (REACT_18) {
|
|
2091
|
+
release()
|
|
2092
|
+
commits.stop()
|
|
2093
|
+
expect(String((thrown as Error | undefined)?.message)).toMatch(
|
|
2094
|
+
/suspended while responding to synchronous input/
|
|
2095
|
+
)
|
|
2096
|
+
expect(container.innerHTML).toBe("")
|
|
2097
|
+
expect(commits.commits).toEqual([""])
|
|
2098
|
+
return
|
|
2099
|
+
}
|
|
2100
|
+
expect(thrown).toBeUndefined()
|
|
2101
|
+
// The page looks hydrated, but the move is still pending: a click and
|
|
2102
|
+
// a state change render with it, and a transition renders the read
|
|
2103
|
+
// again, so the read suspends each one.
|
|
2104
|
+
expect(await update()).toEqual(["0", "0", "0"])
|
|
2105
|
+
expect(container.querySelector("main")?.textContent).toBe(
|
|
2106
|
+
`read by ${ANONYMOUS}`
|
|
2107
|
+
)
|
|
2108
|
+
expect(commits.commits).toEqual([])
|
|
2109
|
+
release()
|
|
2110
|
+
await waitFor(() => {
|
|
2111
|
+
expect(container.querySelector("main")?.textContent).toBe(
|
|
2112
|
+
`read by ${user}`
|
|
2113
|
+
)
|
|
2114
|
+
expect(count()).toBe("3")
|
|
2115
|
+
})
|
|
2116
|
+
commits.stop()
|
|
2117
|
+
expect(recoverable).toEqual([])
|
|
2118
|
+
expect(logged).not.toHaveBeenCalled()
|
|
2119
|
+
expect(container.querySelector("main")).toBe(main)
|
|
2120
|
+
expect(whoamiCallers(browser)).toEqual([user])
|
|
2121
|
+
}
|
|
2122
|
+
)
|
|
2123
|
+
})
|
|
2124
|
+
|
|
2125
|
+
/**
|
|
2126
|
+
* Tabs whose caller is the one a hydrating render shows, or one that never
|
|
2127
|
+
* changes: `useClient()` then has nothing to move on to after hydrating. A
|
|
2128
|
+
* stored session that has ended (an `AuthClient` reports `expired` at once)
|
|
2129
|
+
* or that another origin holds (`signed-in-elsewhere`) leaves the caller
|
|
2130
|
+
* anonymous, like no session at all; a client built with `identity` keeps its
|
|
2131
|
+
* caller for good.
|
|
2132
|
+
*/
|
|
2133
|
+
const steadyTabs = [
|
|
2134
|
+
["without a session", "anonymous"],
|
|
2135
|
+
["whose stored session expired", "expired"],
|
|
2136
|
+
["whose session is signed in elsewhere", "elsewhere"],
|
|
2137
|
+
["of a client built with identity", "identity"],
|
|
2138
|
+
] as const
|
|
2139
|
+
|
|
2140
|
+
/** The client of one of {@link steadyTabs}, and the server's for its page. */
|
|
2141
|
+
async function steadyTab(kind: (typeof steadyTabs)[number][1]) {
|
|
2142
|
+
if (kind === "identity") {
|
|
2143
|
+
const identity = await testAuth({ identity: 7 }).getIdentity()
|
|
2144
|
+
const server = createClient({ network: "ic", identity })
|
|
2145
|
+
const browser = createClient({ network: "ic", identity })
|
|
2146
|
+
made.push(server, browser)
|
|
2147
|
+
return { server, browser, auth: undefined }
|
|
2148
|
+
}
|
|
2149
|
+
const server = replica({ signedIn: false }).client
|
|
2150
|
+
const test = replica({ seed: 7, signedIn: kind !== "anonymous" })
|
|
2151
|
+
if (kind === "expired") test.auth.expire()
|
|
2152
|
+
if (kind === "elsewhere") test.auth.elsewhere()
|
|
2153
|
+
return { server, browser: test.client, auth: test.auth }
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
describe.each(steadyTabs)("a tab %s", (_, kind) => {
|
|
2157
|
+
it("hydrates a component that calls useClient() with one render, and leaves a streamed boundary below it pending", async () => {
|
|
2158
|
+
let renders = 0
|
|
2159
|
+
function Content() {
|
|
2160
|
+
return <article>content</article>
|
|
2161
|
+
}
|
|
2162
|
+
function Nav() {
|
|
2163
|
+
renders++
|
|
2164
|
+
const client = useClient()
|
|
2165
|
+
return (
|
|
2166
|
+
<nav data-caller={client.caller()}>
|
|
2167
|
+
<button onClick={() => void client.signIn()}>sign in</button>
|
|
2168
|
+
<Suspense fallback={<p>fallback</p>}>
|
|
2169
|
+
<Content />
|
|
2170
|
+
</Suspense>
|
|
2171
|
+
</nav>
|
|
2172
|
+
)
|
|
2173
|
+
}
|
|
2174
|
+
const tree = (client: Client) => (
|
|
2175
|
+
<ReactorProvider client={() => client}>
|
|
2176
|
+
<Nav />
|
|
2177
|
+
</ReactorProvider>
|
|
2178
|
+
)
|
|
2179
|
+
const { server, browser, auth } = await steadyTab(kind)
|
|
2180
|
+
container.innerHTML = streamed(
|
|
2181
|
+
renderToString(tree(server)),
|
|
2182
|
+
"<article>content</article>"
|
|
2183
|
+
)
|
|
2184
|
+
await whileLoading(async () => {
|
|
2185
|
+
renders = 0
|
|
2186
|
+
const { recoverable, logged } = await hydrate(tree(browser))
|
|
2187
|
+
await act(macrotask)
|
|
2188
|
+
|
|
2189
|
+
expect(renders).toBe(1)
|
|
2190
|
+
expect(recoverable).toEqual([])
|
|
2191
|
+
expect(logged).not.toHaveBeenCalled()
|
|
2192
|
+
expect(container.querySelector("template")).not.toBeNull()
|
|
2193
|
+
expect(container.querySelector("article")).toBeNull()
|
|
2194
|
+
expect(container.querySelector("p")?.textContent).toBe("fallback")
|
|
2195
|
+
expect(container.querySelector("nav")?.dataset.caller).toBe(
|
|
2196
|
+
browser.caller()
|
|
2197
|
+
)
|
|
2198
|
+
|
|
2199
|
+
// A change of status that leaves the caller as it is renders nothing
|
|
2200
|
+
// either: the client the component holds builds the same keys.
|
|
2201
|
+
if (auth !== undefined && kind !== "anonymous") {
|
|
2202
|
+
const before = browser.authState()
|
|
2203
|
+
act(() => (kind === "expired" ? auth.elsewhere() : auth.expire()))
|
|
2204
|
+
expect(browser.authState().status).not.toBe(before.status)
|
|
2205
|
+
expect(browser.authState().principal).toBe(before.principal)
|
|
2206
|
+
expect(renders).toBe(1)
|
|
2207
|
+
}
|
|
2208
|
+
})
|
|
2209
|
+
})
|
|
2210
|
+
})
|
|
2211
|
+
|
|
2212
|
+
/** What `useAuth()` shows in each of {@link steadyTabs} once it hydrated. */
|
|
2213
|
+
const ownStatus = {
|
|
2214
|
+
anonymous: "anonymous",
|
|
2215
|
+
expired: "expired",
|
|
2216
|
+
elsewhere: "signed-in-elsewhere",
|
|
2217
|
+
identity: "signed-in",
|
|
2218
|
+
} as const
|
|
2219
|
+
|
|
2220
|
+
describe.each(steadyTabs)("a hydrated useAuth() in a tab %s", (_, kind) => {
|
|
2221
|
+
it("shows the server's state while hydrating, then the tab's own, rendering again only for a status that differs", async () => {
|
|
2222
|
+
const statuses: string[] = []
|
|
2223
|
+
const auths: unknown[] = []
|
|
2224
|
+
function Status() {
|
|
2225
|
+
const auth = useAuth()
|
|
2226
|
+
statuses.push(auth.status)
|
|
2227
|
+
auths.push(auth)
|
|
2228
|
+
return <b>{auth.status}</b>
|
|
2229
|
+
}
|
|
2230
|
+
const tree = (client: Client) => (
|
|
2231
|
+
<ReactorProvider client={() => client}>
|
|
2232
|
+
<Status />
|
|
2233
|
+
</ReactorProvider>
|
|
2234
|
+
)
|
|
2235
|
+
const { server, browser } = await steadyTab(kind)
|
|
2236
|
+
container.innerHTML = renderToString(tree(server))
|
|
2237
|
+
expect(container.querySelector("b")?.textContent).toBe("anonymous")
|
|
2238
|
+
statuses.length = auths.length = 0
|
|
2239
|
+
|
|
2240
|
+
const { recoverable, logged } = await hydrate(tree(browser))
|
|
2241
|
+
await act(macrotask)
|
|
2242
|
+
|
|
2243
|
+
expect(recoverable).toEqual([])
|
|
2244
|
+
expect(logged).not.toHaveBeenCalled()
|
|
2245
|
+
expect(container.querySelector("b")?.textContent).toBe(ownStatus[kind])
|
|
2246
|
+
expect(statuses).toEqual(
|
|
2247
|
+
kind === "anonymous" ? ["anonymous"] : ["anonymous", ownStatus[kind]]
|
|
2248
|
+
)
|
|
2249
|
+
expect(new Set(auths).size).toBe(statuses.length)
|
|
2250
|
+
})
|
|
2251
|
+
})
|
|
2252
|
+
|
|
2253
|
+
describe("an anonymous tab", () => {
|
|
2254
|
+
it("hydrates with one render of each component that calls useClient() or useAuth(), and one useAuth() object", async () => {
|
|
2255
|
+
const server = replica({ signedIn: false })
|
|
2256
|
+
const renders = { client: 0, auth: 0 }
|
|
2257
|
+
const auths: unknown[] = []
|
|
2258
|
+
function ClientUser() {
|
|
2259
|
+
renders.client++
|
|
2260
|
+
return <i>{useClient().caller()}</i>
|
|
2261
|
+
}
|
|
2262
|
+
function AuthUser() {
|
|
2263
|
+
renders.auth++
|
|
2264
|
+
const auth = useAuth()
|
|
2265
|
+
auths.push(auth)
|
|
2266
|
+
return <b>{auth.status}</b>
|
|
2267
|
+
}
|
|
2268
|
+
const tree = (client: Client) => (
|
|
2269
|
+
<ReactorProvider client={() => client}>
|
|
2270
|
+
<ClientUser />
|
|
2271
|
+
<AuthUser />
|
|
2272
|
+
</ReactorProvider>
|
|
2273
|
+
)
|
|
2274
|
+
container.innerHTML = renderToString(tree(server.client))
|
|
2275
|
+
renders.client = renders.auth = 0
|
|
2276
|
+
auths.length = 0
|
|
2277
|
+
|
|
2278
|
+
const browser = replica({ signedIn: false })
|
|
2279
|
+
const { recoverable } = await hydrate(tree(browser.client))
|
|
2280
|
+
await act(macrotask)
|
|
2281
|
+
|
|
2282
|
+
expect(recoverable).toEqual([])
|
|
2283
|
+
expect(renders).toEqual({ client: 1, auth: 1 })
|
|
2284
|
+
expect(auths).toHaveLength(1)
|
|
2285
|
+
// A render the session did not cause keeps the object too.
|
|
2286
|
+
act(() => root?.render(tree(browser.client)))
|
|
2287
|
+
expect(renders.auth).toBe(2)
|
|
2288
|
+
expect(new Set(auths).size).toBe(1)
|
|
2289
|
+
// It still follows the session from then on.
|
|
2290
|
+
await act(async () => {
|
|
2291
|
+
await browser.auth.signIn(7)
|
|
2292
|
+
})
|
|
2293
|
+
expect(container.querySelector("b")?.textContent).toBe("signed-in")
|
|
2294
|
+
expect(container.querySelector("i")?.textContent).toBe(
|
|
2295
|
+
browser.auth.getPrincipal()?.toText()
|
|
2296
|
+
)
|
|
2297
|
+
})
|
|
2298
|
+
|
|
2299
|
+
it("leaves a streamed boundary below a useAuth() parent pending", async () => {
|
|
2300
|
+
let renders = 0
|
|
2301
|
+
function Nav() {
|
|
2302
|
+
renders++
|
|
2303
|
+
const { status } = useAuth()
|
|
2304
|
+
return (
|
|
2305
|
+
<nav data-status={status}>
|
|
2306
|
+
<Suspense fallback={<p>fallback</p>}>
|
|
2307
|
+
<article>content</article>
|
|
2308
|
+
</Suspense>
|
|
2309
|
+
</nav>
|
|
2310
|
+
)
|
|
2311
|
+
}
|
|
2312
|
+
const tree = (client: Client) => (
|
|
2313
|
+
<ReactorProvider client={() => client}>
|
|
2314
|
+
<Nav />
|
|
2315
|
+
</ReactorProvider>
|
|
2316
|
+
)
|
|
2317
|
+
const server = replica({ signedIn: false })
|
|
2318
|
+
container.innerHTML = streamed(
|
|
2319
|
+
renderToString(tree(server.client)),
|
|
2320
|
+
"<article>content</article>"
|
|
2321
|
+
)
|
|
2322
|
+
await whileLoading(async () => {
|
|
2323
|
+
renders = 0
|
|
2324
|
+
const browser = replica({ signedIn: false })
|
|
2325
|
+
const { recoverable } = await hydrate(tree(browser.client))
|
|
2326
|
+
await act(macrotask)
|
|
2327
|
+
|
|
2328
|
+
expect(renders).toBe(1)
|
|
2329
|
+
expect(recoverable).toEqual([])
|
|
2330
|
+
expect(container.querySelector("template")).not.toBeNull()
|
|
2331
|
+
expect(container.querySelector("article")).toBeNull()
|
|
2332
|
+
})
|
|
2333
|
+
})
|
|
2334
|
+
})
|