diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index 0df6c8dacfd7..a67f5405493d 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -12,7 +12,8 @@
- `packages/endpoint`: base Endpoint types; `packages/rest`: REST modeling (`resource()`, `RestEndpoint`).
- `packages/core`: framework-agnostic normalized store, actions, Controller, Managers.
- `packages/react`: React bindings (hooks like `useSuspense`, `useLive`, `useQuery`, `DataProvider`).
- - `packages/normalizr`: schema/Entity/normalization; used by rest/core/react.
+ - `packages/vue`: Vue 3 composables (`useSuspense`, `useQuery`, `useLive`, `provideDataClient`).
+ - `packages/normalizr`: schema/Entity/normalization; used by rest/core/react/vue.
### Developer workflows
- Build all packages: `yarn build` (runs `tsc --build` + each workspace build). Clean with `yarn build:clean`.
@@ -34,12 +35,15 @@
- TypeScript 5.9 project references are used; ambient `.d.ts` files are copied during build (`build:copy:ambient`).
### Where to look first
-- High-level usage: root `README.md` and `packages/*/README.md` (react, rest, core) show canonical patterns.
+- High-level usage: root `README.md` and `packages/*/README.md` (react, rest, core, vue) show canonical patterns.
- REST patterns: `docs/rest/*`; Core/Controller/Managers: `docs/core/api/*`.
- Example apps: `examples/todo-app`, `examples/github-app`, `examples/nextjs` demonstrate resources, hooks, mutations, and Managers.
+- Vue patterns: `packages/vue/src/consumers/` for composables, `packages/vue/src/__tests__/` for Vue-specific test patterns.
### Testing patterns
- Prefer `renderDataHook()` from `@data-client/test` with `initialFixtures`/`resolverFixtures` for hook tests.
- Use `nock` for low-level HTTP tests of endpoints. Keep tests under `packages/*/src/**/__tests__`.
+- Vue tests use `@vue/test-utils` with `mount()` and Vue's `Suspense` component for async rendering.
+- Test file naming: `.node.test.ts[x]` for Node-only, `.native.test.ts[x]` for React Native, `.web.ts` for browser tests.
If anything here is unclear or missing (e.g., adding a new package, expanding CI/build), point it out and Iβll refine these instructions.
\ No newline at end of file
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 432e0b9ebe27..ea7282f790e3 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -47,4 +47,14 @@ export default [
'no-console': 'off',
},
},
+ // Disable React-specific rules for Vue package
+ {
+ files: ['packages/vue/**/*.?(m|c)ts?(x)', 'packages/vue/**/*.?(m|c)js?(x)'],
+ rules: {
+ 'react-hooks/rules-of-hooks': 'off',
+ 'react-hooks/exhaustive-deps': 'off',
+ 'react/react-in-jsx-scope': 'off',
+ 'react/jsx-uses-react': 'off',
+ },
+ },
];
diff --git a/jest.config.js b/jest.config.js
index f35a65060bcb..59cac5c9c80d 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -46,6 +46,7 @@ const packages = [
'graphql',
'core',
'react',
+ 'vue',
'normalizr',
'use-enhanced-reducer',
'img',
diff --git a/packages/vue/.gitignore b/packages/vue/.gitignore
new file mode 100644
index 000000000000..1107bdde3159
--- /dev/null
+++ b/packages/vue/.gitignore
@@ -0,0 +1,9 @@
+/lib
+/dist
+/ts3.4
+/index.d.ts
+node_modules
+.DS_Store
+*.log
+*.tgz
+coverage/
\ No newline at end of file
diff --git a/packages/vue/LICENSE b/packages/vue/LICENSE
new file mode 100644
index 000000000000..362201c963fc
--- /dev/null
+++ b/packages/vue/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright 2022 Nathaniel Tucker
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/packages/vue/README.md b/packages/vue/README.md
new file mode 100644
index 000000000000..8f8188962f31
--- /dev/null
+++ b/packages/vue/README.md
@@ -0,0 +1,309 @@
+
+
+
+
+The scalable way to build applications with [dynamic data](https://dataclient.io/docs/getting-started/mutations).
+
+[Declarative resouce definitons](https://dataclient.io/docs/getting-started/resource) for [REST](https://dataclient.io/rest), [GraphQL](https://dataclient.io/graphql), [Websockets+SSE](https://dataclient.io/docs/concepts/managers#data-stream) and [more](https://dataclient.io/rest/api/Endpoint)
+ [Performant rendering](https://dataclient.io/docs/getting-started/data-dependency) in [Vue 3](https://vuejs.org/)
+
+Schema driven. Zero updater functions.
+
+
+
+[](https://circleci.com/gh/reactive/data-client)
+[](https://app.codecov.io/gh/reactive/data-client?branch=master)
+[](https://github.com/reactive/data-client/issues 'Percentage of issues still open')
+[](https://bundlephobia.com/result?p=@data-client/vue)
+[](https://www.npmjs.com/package/@data-client/vue)
+[](http://makeapullrequest.com)
+[](https://chatgpt.com/g/g-682609591fe48191a6850901521b4e4b-typescript-rest-codegen)
+[](https://discord.gg/35nb8Mz)
+
+**[πRead The Docs](https://dataclient.io/docs)** | [πGetting Started](https://dataclient.io/docs/getting-started/installation) | [π€Codegen](https://chatgpt.com/g/g-682609591fe48191a6850901521b4e4b-typescript-rest-codegen)
+
+
+
+## Installation
+
+```bash
+npm install --save @data-client/vue @data-client/rest @data-client/test
+```
+
+For more details, see [the Installation docs page](https://dataclient.io/docs/getting-started/installation).
+
+## Usage (alpha)
+
+### Simple [TypeScript definition](https://dataclient.io/rest/api/Entity)
+
+```typescript
+class User extends Entity {
+ id = '';
+ username = '';
+}
+
+class Article extends Entity {
+ id = '';
+ title = '';
+ body = '';
+ author = User.fromJS();
+ createdAt = Temporal.Instant.fromEpochMilliseconds(0);
+
+ static schema = {
+ author: User,
+ createdAt: Temporal.Instant.from,
+ };
+}
+```
+
+### Create [collection of API Endpoints](https://dataclient.io/docs/getting-started/resource)
+
+```typescript
+const UserResource = resource({
+ path: '/users/:id',
+ schema: User,
+ optimistic: true,
+});
+
+const ArticleResource = resource({
+ path: '/articles/:id',
+ schema: Article,
+ searchParams: {} as { author?: string },
+ optimistic: true,
+ paginationField: 'cursor',
+});
+```
+
+### Provide the Data Client
+
+Call `provideDataClient()` once in your root component's setup. This creates the controller, store, and managers, and provides them via Vue's provide/inject.
+
+```ts
+// App.vue (script setup)
+import { provideDataClient } from '@data-client/vue';
+
+provideDataClient({
+ // optional overrides
+ // managers: getDefaultManagers(),
+ // initialState,
+ // Controller,
+ // gcPolicy,
+});
+```
+
+### One line [data binding](https://dataclient.io/docs/getting-started/data-dependency)
+
+```vue
+
+
+
+ {{ article.title }} by {{ article.author.username }}
+
+ {{ article.body }}
+
+
+
+
+```
+
+### [Reactive Mutations](https://dataclient.io/docs/getting-started/mutations)
+
+```vue
+
+
+
+
+
+```
+
+### [Subscriptions](https://dataclient.io/docs/api/useLive)
+
+```vue
+
+ {{ price.value }}
+
+
+
+```
+
+### [Type-safe Imperative Actions](https://dataclient.io/docs/api/Controller)
+
+```typescript
+const ctrl = useController();
+await ctrl.fetch(ArticleResource.update, { id }, articleData);
+await ctrl.fetchIfStale(ArticleResource.get, { id });
+ctrl.expireAll(ArticleResource.getList);
+ctrl.invalidate(ArticleResource.get, { id });
+ctrl.invalidateAll(ArticleResource.getList);
+ctrl.setResponse(ArticleResource.get, { id }, articleData);
+ctrl.set(Article, { id }, articleData);
+```
+
+### [Programmatic queries](https://dataclient.io/rest/api/Query)
+
+```typescript
+const queryTotalVotes = new schema.Query(
+ new schema.Collection([BlogPost]),
+ posts => posts.reduce((total, post) => total + post.votes, 0),
+);
+
+const totalVotes = useQuery(queryTotalVotes);
+const totalVotesForUser = useQuery(queryTotalVotes, { userId });
+```
+
+```typescript
+const groupTodoByUser = new schema.Query(
+ TodoResource.getList.schema,
+ todos => Object.groupBy(todos, todo => todo.userId),
+);
+const todosByUser = useQuery(groupTodoByUser);
+```
+
+### [Powerful Middlewares](https://dataclient.io/docs/concepts/managers)
+
+```ts
+class LoggingManager implements Manager {
+ middleware: Middleware = controller => next => async action => {
+ console.log('before', action, controller.getState());
+ await next(action);
+ console.log('after', action, controller.getState());
+ };
+
+ cleanup() {}
+}
+```
+
+```ts
+class TickerStream implements Manager {
+ middleware: Middleware = controller => {
+ this.handleMsg = msg => {
+ controller.set(Ticker, { id: msg.id }, msg);
+ };
+ return next => action => next(action);
+ };
+
+ init() {
+ this.websocket = new WebSocket('wss://ws-feed.myexchange.com');
+ this.websocket.onmessage = event => {
+ const msg = JSON.parse(event.data);
+ this.handleMsg(msg);
+ };
+ }
+ cleanup() {
+ this.websocket.close();
+ }
+}
+```
+
+### [Integrated data mocking](https://dataclient.io/docs/api/Fixtures)
+
+```vue
+
+
+
+
+
+
+
+```
+
+### ...all typed ...fast ...and consistent
+
+For the small price of 9kb gziped. [πGet started now](https://dataclient.io/docs/getting-started/installation)
+
+## Features
+
+- [x]  Strong [Typescript](https://www.typescriptlang.org/) inference
+- [x] π Vue 3 [Composition API](https://vuejs.org/guide/extras/composition-api-faq.html) composables
+- [x] π£ [Declarative API](https://dataclient.io/docs/getting-started/data-dependency)
+- [x] π Composition over configuration
+- [x] π° [Normalized](https://dataclient.io/docs/concepts/normalization) caching
+- [x] π₯ Tiny bundle footprint
+- [x] π Automatic overfetching elimination
+- [x] β¨ Fast [optimistic updates](https://dataclient.io/rest/guides/optimistic-updates)
+- [x] π§ [Flexible](https://dataclient.io/docs/getting-started/resource) to fit any API design (one size fits all)
+- [x] π§ [Debugging and inspection](https://dataclient.io/docs/getting-started/debugging) via browser extension
+- [x] π³ Tree-shakable (only use what you need)
+- [x] π [Subscriptions](https://dataclient.io/docs/api/useSubscription)
+- [x] π [Storybook mocking](https://dataclient.io/docs/guides/storybook)
+- [x] π― [Declarative cache lifetime policy](https://dataclient.io/docs/concepts/expiry-policy)
+- [x] π§
[Composable middlewares](https://dataclient.io/docs/api/Manager)
+- [x] π½ Global data consistency guarantees
+- [x] π Automatic race condition elimination
+- [x] π― Global referential equality guarantees
+
+## API
+
+- Rendering: `useSuspense()`, `useLive()`, `useCache()`, `useDLE()`, `useQuery()`, `useLoading()`, `useDebounce()`, `useCancelling()`
+- Event handling: `useController()` returns [Controller](https://dataclient.io/docs/api/Controller)
+ - `ctrl.fetch`
+ - `ctrl.fetchIfStale`
+ - `ctrl.expireAll`
+ - `ctrl.invalidate`
+ - `ctrl.invalidateAll`
+ - `ctrl.resetEntireStore`
+ - `ctrl.set`
+ - `ctrl.setResponse`
+ - `ctrl.setError`
+ - `ctrl.resolve`
+ - `ctrl.subscribe`
+ - `ctrl.unsubscribe`
+- Components: ` `, ` `
+- Middleware: `LogoutManager`, `NetworkManager`, `SubscriptionManager`, `PollingSubscription`, `DevToolsManager`
diff --git a/packages/vue/data_client_logo_and_text.svg b/packages/vue/data_client_logo_and_text.svg
new file mode 100644
index 000000000000..bf0bfa68242e
--- /dev/null
+++ b/packages/vue/data_client_logo_and_text.svg
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/vue/node.mjs b/packages/vue/node.mjs
new file mode 100644
index 000000000000..2b8395cdb631
--- /dev/null
+++ b/packages/vue/node.mjs
@@ -0,0 +1 @@
+export * from './dist/index.js';
diff --git a/packages/vue/package.json b/packages/vue/package.json
new file mode 100644
index 000000000000..4e64406a2c8c
--- /dev/null
+++ b/packages/vue/package.json
@@ -0,0 +1,120 @@
+{
+ "name": "@data-client/vue",
+ "version": "0.0.5",
+ "description": "Async State Management without the Management. REST, GraphQL, SSE, Websockets, Fetch",
+ "homepage": "https://dataclient.io",
+ "repository": {
+ "type": "git",
+ "url": "git+ssh://git@github.com:reactive/data-client.git",
+ "directory": "packages/vue"
+ },
+ "bugs": {
+ "url": "https://github.com/reactive/data-client/issues"
+ },
+ "keywords": [
+ "vue",
+ "data",
+ "cache",
+ "flux",
+ "suspense",
+ "fetch",
+ "composable",
+ "networking",
+ "async",
+ "typescript",
+ "async",
+ "data fetching",
+ "data cache",
+ "reactive",
+ "state management",
+ "api client",
+ "api",
+ "normalized cache",
+ "swr",
+ "query",
+ "front-end",
+ "mobile",
+ "web",
+ "middleware",
+ "websocket",
+ "REST",
+ "GraphQL",
+ "RPC",
+ "sse",
+ "declarative",
+ "dynamic data",
+ "mutations"
+ ],
+ "main": "dist/index.js",
+ "module": "lib/index.js",
+ "unpkg": "dist/index.umd.min.js",
+ "types": "lib/index.d.ts",
+ "exports": {
+ ".": {
+ "module": "./lib/index.js",
+ "import": "./node.mjs",
+ "require": "./dist/index.js",
+ "browser": "./lib/index.js",
+ "default": "./lib/index.js"
+ },
+ "./package.json": "./package.json"
+ },
+ "type": "module",
+ "engines": {
+ "node": "^12.17 || ^13.7 || >=14"
+ },
+ "files": [
+ "src",
+ "dist",
+ "lib",
+ "ts3.4",
+ "node.mjs",
+ "LICENSE",
+ "README.md",
+ "./data_client_logo_and_text.svg",
+ "./typescript.svg"
+ ],
+ "scripts": {
+ "build:lib": "NODE_ENV=production BROWSERSLIST_ENV='2020' POLYFILL_TARGETS='chrome>88,safari>14' yarn g:babel --out-dir lib",
+ "build:js:node": "BROWSERSLIST_ENV=node12 yarn g:rollup",
+ "build:js:browser": "BROWSERSLIST_ENV=legacy yarn g:rollup",
+ "build:bundle": "yarn g:runs build:js:\\* && echo '{\"type\":\"commonjs\"}' > dist/package.json",
+ "build:clean": "yarn g:clean index.d.ts",
+ "build:legacy-types": "yarn g:downtypes lib ts3.4",
+ "build": "run build:lib && run build:bundle",
+ "dev": "run build:lib -w",
+ "prepare": "run build:lib",
+ "prepack": "run prepare && run build:bundle"
+ },
+ "author": "Nathaniel Tucker (https://github.com/ntucker)",
+ "funding": "https://github.com/sponsors/ntucker",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@babel/runtime": "^7.20.0",
+ "@data-client/core": "^0.15.0-beta-0"
+ },
+ "peerDependencies": {
+ "@types/vue": "^3.0.0",
+ "vue": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/vue": {
+ "optional": true
+ }
+ },
+ "devDependencies": {
+ "@anansi/browserslist-config": "^1.4.2",
+ "@data-client/rest": "workspace:*",
+ "@data-client/test": "workspace:*",
+ "@jest/globals": "^30.0.0",
+ "@js-temporal/polyfill": "^0.5.0",
+ "@types/jest": "30.0.0",
+ "@types/node": "^22.0.0",
+ "@vue/test-utils": "^2.4.0",
+ "jest-environment-jsdom": "^30.0.0",
+ "jest-mock": "^30.0.0",
+ "nock": "13.3.1",
+ "rollup-plugins": "workspace:*",
+ "vue": "^3.4.0"
+ }
+}
diff --git a/packages/vue/rollup.config.mjs b/packages/vue/rollup.config.mjs
new file mode 100644
index 000000000000..d24d8d5740c3
--- /dev/null
+++ b/packages/vue/rollup.config.mjs
@@ -0,0 +1,90 @@
+import {
+ babel,
+ commonjs,
+ filesize,
+ json,
+ resolve,
+ replace,
+ terser,
+ typeConfig,
+ onwarn,
+} from 'rollup-plugins';
+
+import pkg from './package.json' with { type: 'json' };
+
+const dependencies = Object.keys(pkg.dependencies)
+ .concat(Object.keys(pkg.peerDependencies))
+ .filter(dep => !['@babel/runtime'].includes(dep));
+const peers = Object.keys(pkg.peerDependencies);
+
+const extensions = ['.js', '.ts', '.tsx', '.mjs', '.json', '.node', '.jsx'];
+process.env.NODE_ENV = 'production';
+
+function isExternal(id) {
+ return (
+ // when we import contexts in our other entry points
+ id === '../../index.js' ||
+ id === '../index.js' ||
+ dependencies.some(dep => dep === id || id.startsWith(dep))
+ );
+}
+
+const configs = [];
+if (process.env.BROWSERSLIST_ENV !== 'node12') {
+ // browser-friendly UMD build
+ configs.push({
+ input: 'src/index.ts',
+ external: id => peers.some(dep => dep === id || id.startsWith(dep)),
+ output: [
+ {
+ file: pkg.unpkg,
+ format: 'umd',
+ name: 'VDC',
+ inlineDynamicImports: true,
+ globals: {
+ vue: 'Vue',
+ },
+ },
+ ],
+ onwarn,
+ plugins: [
+ babel({
+ exclude: ['node_modules/**', '/**__tests__/**'],
+ extensions,
+ rootMode: 'upward',
+ babelHelpers: 'runtime',
+ caller: { polyfillMethod: false },
+ }),
+ replace({
+ 'process.env.NODE_ENV': JSON.stringify('production'),
+ preventAssignment: true,
+ }),
+ resolve({ extensions }),
+ commonjs({ extensions }),
+ json(),
+ terser({}),
+ filesize({ showBrotliSize: true }),
+ ],
+ });
+ configs.push(typeConfig);
+} else {
+ // node-friendly commonjs build
+ configs.push({
+ input: 'src/index.ts',
+ external: isExternal,
+ output: [{ file: pkg.main, format: 'cjs', inlineDynamicImports: true }],
+ onwarn,
+ plugins: [
+ babel({
+ exclude: ['node_modules/**', '**/__tests__/**', '**/*.d.ts'],
+ extensions,
+ rootMode: 'upward',
+ babelHelpers: 'runtime',
+ }),
+ replace({ 'process.env.CJS': 'true', preventAssignment: true }),
+ resolve({ extensions }),
+ commonjs({ extensions }),
+ ],
+ });
+}
+export default configs;
diff --git a/packages/vue/src/__tests__/index.test.ts b/packages/vue/src/__tests__/index.test.ts
new file mode 100644
index 000000000000..608a2feaa3da
--- /dev/null
+++ b/packages/vue/src/__tests__/index.test.ts
@@ -0,0 +1,10 @@
+import { describe, it, expect } from '@jest/globals';
+
+import { Controller, actionTypes } from '../index';
+
+describe('@data-client/vue', () => {
+ it('should export basic types from core', () => {
+ expect(Controller).toBeDefined();
+ expect(actionTypes).toBeDefined();
+ });
+});
diff --git a/packages/vue/src/__tests__/useDebounce.web.ts b/packages/vue/src/__tests__/useDebounce.web.ts
new file mode 100644
index 000000000000..c2e539f9b55f
--- /dev/null
+++ b/packages/vue/src/__tests__/useDebounce.web.ts
@@ -0,0 +1,264 @@
+import { mount } from '@vue/test-utils';
+import { defineComponent, h, nextTick, ref } from 'vue';
+
+import useDebounce from '../consumers/useDebounce';
+
+describe('vue useDebounce()', () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ });
+
+ afterEach(() => {
+ jest.runOnlyPendingTimers();
+ jest.useRealTimers();
+ });
+
+ it('debounces value updates with correct delay', async () => {
+ const TestComponent = defineComponent({
+ name: 'TestComponent',
+ setup() {
+ const query = ref('initial');
+ const [debouncedQuery, isPending] = useDebounce(query, 200);
+
+ const updateQuery = (newValue: string) => {
+ query.value = newValue;
+ };
+
+ return () =>
+ h('div', [
+ h('input', {
+ value: query.value,
+ onInput: (e: any) => updateQuery(e.target.value),
+ 'data-testid': 'input',
+ }),
+ h('div', { 'data-testid': 'original' }, query.value),
+ h('div', { 'data-testid': 'debounced' }, debouncedQuery.value),
+ h('div', { 'data-testid': 'pending' }, isPending.value.toString()),
+ ]);
+ },
+ });
+
+ const wrapper = mount(TestComponent);
+
+ // Initially, debounced value should match original
+ expect(wrapper.find('[data-testid="original"]').text()).toBe('initial');
+ expect(wrapper.find('[data-testid="debounced"]').text()).toBe('initial');
+ expect(wrapper.find('[data-testid="pending"]').text()).toBe('false');
+
+ // Update the input
+ const input = wrapper.find('[data-testid="input"]');
+ await input.setValue('updated');
+ await nextTick();
+
+ // Original should update immediately, debounced should not
+ expect(wrapper.find('[data-testid="original"]').text()).toBe('updated');
+ expect(wrapper.find('[data-testid="debounced"]').text()).toBe('initial');
+ expect(wrapper.find('[data-testid="pending"]').text()).toBe('true');
+
+ // Fast-forward time by less than delay
+ jest.advanceTimersByTime(100);
+ await nextTick();
+
+ // Debounced should still be old value
+ expect(wrapper.find('[data-testid="debounced"]').text()).toBe('initial');
+ expect(wrapper.find('[data-testid="pending"]').text()).toBe('true');
+
+ // Fast-forward time by remaining delay
+ jest.advanceTimersByTime(100);
+ await nextTick();
+
+ // Now debounced should update
+ expect(wrapper.find('[data-testid="debounced"]').text()).toBe('updated');
+ expect(wrapper.find('[data-testid="pending"]').text()).toBe('false');
+ });
+
+ it('cancels previous timeout when value changes rapidly', async () => {
+ const TestComponent = defineComponent({
+ name: 'TestComponent',
+ setup() {
+ const query = ref('initial');
+ const [debouncedQuery, isPending] = useDebounce(query, 200);
+
+ const updateQuery = (newValue: string) => {
+ query.value = newValue;
+ };
+
+ return () =>
+ h('div', [
+ h('input', {
+ value: query.value,
+ onInput: (e: any) => updateQuery(e.target.value),
+ 'data-testid': 'input',
+ }),
+ h('div', { 'data-testid': 'debounced' }, debouncedQuery.value),
+ h('div', { 'data-testid': 'pending' }, isPending.value.toString()),
+ ]);
+ },
+ });
+
+ const wrapper = mount(TestComponent);
+
+ // Rapid updates
+ const input = wrapper.find('[data-testid="input"]');
+ await input.setValue('first');
+ await nextTick();
+
+ expect(wrapper.find('[data-testid="pending"]').text()).toBe('true');
+
+ // Fast-forward partway
+ jest.advanceTimersByTime(100);
+
+ // Another update before timeout
+ await input.setValue('second');
+ await nextTick();
+
+ // Fast-forward full delay from second update
+ jest.advanceTimersByTime(200);
+ await nextTick();
+
+ // Should show the second value, not first
+ expect(wrapper.find('[data-testid="debounced"]').text()).toBe('second');
+ expect(wrapper.find('[data-testid="pending"]').text()).toBe('false');
+ });
+
+ it('respects updatable parameter', async () => {
+ const TestComponent = defineComponent({
+ name: 'TestComponent',
+ setup() {
+ const query = ref('initial');
+ const updatable = ref(true);
+ const [debouncedQuery, isPending] = useDebounce(query, 200, updatable);
+
+ const updateQuery = (newValue: string) => {
+ query.value = newValue;
+ };
+
+ const toggleUpdatable = () => {
+ updatable.value = !updatable.value;
+ };
+
+ return () =>
+ h('div', [
+ h('input', {
+ value: query.value,
+ onInput: (e: any) => updateQuery(e.target.value),
+ 'data-testid': 'input',
+ }),
+ h(
+ 'button',
+ {
+ onClick: toggleUpdatable,
+ 'data-testid': 'toggle',
+ },
+ `Updatable: ${updatable.value}`,
+ ),
+ h('div', { 'data-testid': 'debounced' }, debouncedQuery.value),
+ h('div', { 'data-testid': 'pending' }, isPending.value.toString()),
+ ]);
+ },
+ });
+
+ const wrapper = mount(TestComponent);
+
+ // Disable updates
+ await wrapper.find('[data-testid="toggle"]').trigger('click');
+ await nextTick();
+
+ // Update input
+ await wrapper.find('[data-testid="input"]').setValue('disabled');
+ await nextTick();
+
+ // Should not be pending since updates are disabled
+ expect(wrapper.find('[data-testid="pending"]').text()).toBe('false');
+
+ // Fast-forward time
+ jest.advanceTimersByTime(300);
+ await nextTick();
+
+ // Debounced value should not have updated
+ expect(wrapper.find('[data-testid="debounced"]').text()).toBe('initial');
+ });
+
+ it('cleans up timeout on unmount', async () => {
+ const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout');
+
+ const TestComponent = defineComponent({
+ name: 'TestComponent',
+ setup() {
+ const query = ref('initial');
+ const [debouncedQuery] = useDebounce(query, 200);
+
+ const updateQuery = (newValue: string) => {
+ query.value = newValue;
+ };
+
+ return () =>
+ h('div', [
+ h('input', {
+ value: query.value,
+ onInput: (e: any) => updateQuery(e.target.value),
+ 'data-testid': 'input',
+ }),
+ h('div', { 'data-testid': 'debounced' }, debouncedQuery.value),
+ ]);
+ },
+ });
+
+ const wrapper = mount(TestComponent);
+
+ // Update input to trigger timeout
+ await wrapper.find('[data-testid="input"]').setValue('will unmount');
+ await nextTick();
+
+ // Unmount component
+ wrapper.unmount();
+
+ // Should have called clearTimeout
+ expect(clearTimeoutSpy).toHaveBeenCalled();
+
+ clearTimeoutSpy.mockRestore();
+ });
+
+ it('works with reactive refs as input', async () => {
+ const TestComponent = defineComponent({
+ name: 'TestComponent',
+ setup() {
+ const query = ref('initial');
+ const [debouncedQuery, isPending] = useDebounce(query, 200);
+
+ const updateQuery = (newValue: string) => {
+ query.value = newValue;
+ };
+
+ return () =>
+ h('div', [
+ h(
+ 'button',
+ {
+ onClick: () => updateQuery('reactive'),
+ 'data-testid': 'button',
+ },
+ 'Update',
+ ),
+ h('div', { 'data-testid': 'debounced' }, debouncedQuery.value),
+ h('div', { 'data-testid': 'pending' }, isPending.value.toString()),
+ ]);
+ },
+ });
+
+ const wrapper = mount(TestComponent);
+
+ // Click to update
+ await wrapper.find('[data-testid="button"]').trigger('click');
+ await nextTick();
+
+ expect(wrapper.find('[data-testid="pending"]').text()).toBe('true');
+
+ // Fast-forward time
+ jest.advanceTimersByTime(200);
+ await nextTick();
+
+ expect(wrapper.find('[data-testid="debounced"]').text()).toBe('reactive');
+ expect(wrapper.find('[data-testid="pending"]').text()).toBe('false');
+ });
+});
diff --git a/packages/vue/src/__tests__/useFetch.web.ts b/packages/vue/src/__tests__/useFetch.web.ts
new file mode 100644
index 000000000000..61e8479298fc
--- /dev/null
+++ b/packages/vue/src/__tests__/useFetch.web.ts
@@ -0,0 +1,200 @@
+import { mount } from '@vue/test-utils';
+import nock from 'nock';
+import { defineComponent, h, nextTick } from 'vue';
+
+// Reuse the same endpoints/fixtures used by the React tests
+import {
+ CoolerArticleResource,
+ StaticArticleResource,
+} from '../../../../__tests__/new';
+// Minimal shared fixture (copied from React test fixtures)
+const payload = {
+ id: 5,
+ title: 'hi ho',
+ content: 'whatever',
+ tags: ['a', 'best', 'react'],
+};
+import useFetch from '../consumers/useFetch';
+import { provideDataClient } from '../providers/provideDataClient';
+
+async function flush() {
+ await Promise.resolve();
+ await nextTick();
+ await new Promise(resolve => setTimeout(resolve, 0));
+}
+
+async function flushUntil(wrapper: any, predicate: () => boolean, tries = 100) {
+ for (let i = 0; i < tries; i++) {
+ if (predicate()) return;
+ await flush();
+ }
+}
+
+describe('vue useFetch()', () => {
+ let mynock: nock.Scope;
+ beforeAll(() => {
+ nock(/.*/)
+ .persist()
+ .defaultReplyHeaders({
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Headers': 'Access-Token',
+ 'Content-Type': 'application/json',
+ })
+ .options(/.*/)
+ .reply(200);
+ });
+ beforeEach(() => {
+ mynock = nock(/.*/).defaultReplyHeaders({
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Headers': 'Access-Token',
+ 'Content-Type': 'application/json',
+ });
+ });
+
+ afterEach(() => {
+ nock.cleanAll();
+ });
+
+ const ProvideWrapper = defineComponent({
+ name: 'ProvideWrapper',
+ setup(_props, { slots, expose }) {
+ const { controller } = provideDataClient();
+ expose({ controller });
+ return () => (slots.default ? slots.default() : h('div'));
+ },
+ });
+
+ it('should dispatch singles', async () => {
+ const fetchMock = jest.fn(() => payload);
+ mynock.get(`/article-cooler/${payload.id}`).reply(200, fetchMock);
+
+ const Comp = defineComponent({
+ name: 'FetchTester',
+ setup() {
+ const p = useFetch(CoolerArticleResource.get, { id: payload.id });
+ return () => h('div', { class: 'root' }, String(!!p));
+ },
+ });
+
+ const wrapper = mount(ProvideWrapper, {
+ slots: { default: () => h(Comp) },
+ });
+
+ // Wait for the fetch to happen
+ await flushUntil(wrapper, () => fetchMock.mock.calls.length > 0);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('should not dispatch with null params, then dispatch after set', async () => {
+ const fetchMock = jest.fn(() => payload);
+ mynock.get(`/article-cooler/${payload.id}`).reply(200, fetchMock);
+
+ let params: any = null;
+ const Comp = defineComponent({
+ name: 'FetchTesterNull',
+ setup() {
+ // reactive params via closure re-render with slot remount
+ useFetch(CoolerArticleResource.get as any, params);
+ return () => h('div');
+ },
+ });
+
+ const wrapper = mount(ProvideWrapper, {
+ slots: { default: () => h(Comp) },
+ });
+ await flush();
+ expect(fetchMock).toHaveBeenCalledTimes(0);
+
+ // change params and remount child to re-run setup
+ params = { id: payload.id };
+ wrapper.unmount();
+ const wrapper2 = mount(ProvideWrapper, {
+ slots: { default: () => h(Comp) },
+ });
+ await flushUntil(wrapper2, () => fetchMock.mock.calls.length > 0);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('should respect expiry and not refetch when fresh', async () => {
+ const fetchMock = jest.fn(() => payload);
+ mynock.get(`/article-cooler/${payload.id}`).reply(200, fetchMock);
+
+ const Child = defineComponent({
+ name: 'FetchChild',
+ setup() {
+ useFetch(CoolerArticleResource.get, { id: payload.id });
+ return () => h('div');
+ },
+ });
+
+ const Parent = defineComponent({
+ name: 'Parent',
+ setup(_props, { expose }) {
+ const { controller } = provideDataClient();
+ let idx = 0;
+ const remount = () => {
+ idx++;
+ };
+ expose({ controller, remount });
+ return () => h('div', [h(Child, { key: idx })]);
+ },
+ });
+
+ const wrapper = mount(Parent);
+ await flushUntil(wrapper, () => fetchMock.mock.calls.length > 0);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+
+ // Remount child inside same provider should not refetch while data is fresh
+ (wrapper.vm as any).remount();
+ await flush();
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('should dispatch with resource and endpoint expiry overrides', async () => {
+ const mock1 = jest.fn(() => payload);
+ const mock2 = jest.fn(() => payload);
+ const mock3 = jest.fn(() => payload);
+
+ mynock
+ .get(`/article-static/${payload.id}`)
+ .reply(200, mock1)
+ .get(`/article-static/${payload.id}`)
+ .reply(200, mock2)
+ .get(`/article-static/${payload.id}`)
+ .reply(200, mock3);
+
+ const Comp1 = defineComponent({
+ name: 'FetchStaticGet',
+ setup() {
+ useFetch(StaticArticleResource.get, { id: payload.id });
+ return () => h('div');
+ },
+ });
+ const Comp2 = defineComponent({
+ name: 'FetchStaticLong',
+ setup() {
+ useFetch(StaticArticleResource.longLiving, { id: payload.id });
+ return () => h('div');
+ },
+ });
+ const Comp3 = defineComponent({
+ name: 'FetchStaticNeverRetry',
+ setup() {
+ useFetch(StaticArticleResource.neverRetryOnError, { id: payload.id });
+ return () => h('div');
+ },
+ });
+
+ const w1 = mount(ProvideWrapper, { slots: { default: () => h(Comp1) } });
+ await flushUntil(w1, () => mock1.mock.calls.length > 0);
+ expect(mock1).toHaveBeenCalled();
+
+ const w2 = mount(ProvideWrapper, { slots: { default: () => h(Comp2) } });
+ await flushUntil(w2, () => mock2.mock.calls.length > 0);
+ expect(mock2).toHaveBeenCalled();
+
+ const w3 = mount(ProvideWrapper, { slots: { default: () => h(Comp3) } });
+ await flushUntil(w3, () => mock3.mock.calls.length > 0);
+ expect(mock3).toHaveBeenCalled();
+ });
+});
diff --git a/packages/vue/src/__tests__/useLive.web.ts b/packages/vue/src/__tests__/useLive.web.ts
new file mode 100644
index 000000000000..57b6e3fb292b
--- /dev/null
+++ b/packages/vue/src/__tests__/useLive.web.ts
@@ -0,0 +1,162 @@
+import { mount } from '@vue/test-utils';
+import nock from 'nock';
+import { defineComponent, h, nextTick, Suspense } from 'vue';
+
+// Reuse the same endpoints/fixtures used by the React tests
+import { CoolerArticleResource } from '../../../../__tests__/new';
+// Minimal shared fixture (copied from React test fixtures)
+const payload = {
+ id: 5,
+ title: 'hi ho',
+ content: 'whatever',
+ tags: ['a', 'best', 'react'],
+};
+import useLive from '../consumers/useLive';
+import { provideDataClient } from '../providers/provideDataClient';
+
+describe('vue useLive()', () => {
+ async function flushUntil(
+ wrapper: any,
+ predicate: () => boolean,
+ tries = 100,
+ ) {
+ for (let i = 0; i < tries; i++) {
+ if (predicate()) return;
+ await Promise.resolve();
+ await nextTick();
+ await new Promise(resolve => setTimeout(resolve, 0));
+ }
+ }
+
+ beforeAll(() => {
+ nock(/.*/)
+ .persist()
+ .defaultReplyHeaders({
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Headers': 'Access-Token',
+ 'Content-Type': 'application/json',
+ })
+ .options(/.*/)
+ .reply(200)
+ .get(`/article-cooler/${payload.id}`)
+ .reply(200, payload)
+ .put(`/article-cooler/${payload.id}`)
+ .reply(200, (uri, requestBody: any) => ({
+ ...payload,
+ ...requestBody,
+ }));
+ });
+
+ afterAll(() => {
+ nock.cleanAll();
+ });
+
+ const ArticleComp = defineComponent({
+ name: 'ArticleComp',
+ async setup() {
+ const article = await useLive(CoolerArticleResource.get, {
+ id: payload.id,
+ });
+ return () =>
+ h('div', [
+ h('h3', (article as any).value.title),
+ h('p', (article as any).value.content),
+ ]);
+ },
+ });
+
+ const ProvideWrapper = defineComponent({
+ name: 'ProvideWrapper',
+ setup(_props, { slots, expose }) {
+ const { controller } = provideDataClient();
+ expose({ controller });
+ return () =>
+ h(
+ Suspense,
+ {},
+ {
+ default: () => (slots.default ? slots.default() : h(ArticleComp)),
+ fallback: () => h('div', { class: 'fallback' }, 'Loading'),
+ },
+ );
+ },
+ });
+
+ it('suspends on empty store, then renders after fetch resolves', async () => {
+ const wrapper = mount(ProvideWrapper, {
+ slots: { default: () => h(ArticleComp) },
+ });
+
+ // Initially should render fallback while Suspense is pending
+ expect(wrapper.find('.fallback').exists()).toBe(true);
+
+ // Flush pending promises/ticks until content renders
+ await flushUntil(wrapper, () => wrapper.find('h3').exists());
+
+ const title = wrapper.find('h3');
+ const content = wrapper.find('p');
+ expect(title.exists()).toBe(true);
+ expect(content.exists()).toBe(true);
+ expect(title.text()).toBe(payload.title);
+ expect(content.text()).toBe(payload.content);
+ });
+
+ it('re-renders when controller.setResponse() updates data', async () => {
+ const wrapper = mount(ProvideWrapper, {
+ slots: { default: () => h(ArticleComp) },
+ });
+ // Wait for initial render
+ await flushUntil(wrapper, () => wrapper.find('h3').exists());
+
+ // Verify initial values
+ expect(wrapper.find('h3').text()).toBe(payload.title);
+ expect(wrapper.find('p').text()).toBe(payload.content);
+
+ // Update the store using controller.setResponse
+ const exposed: any = wrapper.vm as any;
+ const newTitle = payload.title + ' updated';
+ const newContent = (payload as any).content + ' v2';
+ exposed.controller.setResponse(
+ CoolerArticleResource.get,
+ { id: payload.id },
+ { ...payload, title: newTitle, content: newContent },
+ );
+
+ await flushUntil(wrapper, () => wrapper.find('h3').text() === newTitle);
+
+ expect(wrapper.find('h3').text()).toBe(newTitle);
+ expect(wrapper.find('p').text()).toBe(newContent);
+ });
+
+ it('stays subscribed and reacts to server updates', async () => {
+ // This test verifies that useLive includes subscription behavior
+ const wrapper = mount(ProvideWrapper, {
+ slots: { default: () => h(ArticleComp) },
+ });
+
+ // Wait for initial render
+ await flushUntil(wrapper, () => wrapper.find('h3').exists());
+
+ // Verify initial values
+ expect(wrapper.find('h3').text()).toBe(payload.title);
+
+ const exposed: any = wrapper.vm as any;
+
+ // Simulate a server update that would trigger subscription
+ const updatedTitle = payload.title + ' live updated';
+ const updatedContent = payload.content + ' live updated';
+
+ // Use controller.fetch to simulate an update that would come from subscription
+ await exposed.controller.fetch(
+ CoolerArticleResource.update,
+ { id: payload.id },
+ { title: updatedTitle, content: updatedContent },
+ );
+
+ // Wait for re-render with new data
+ await flushUntil(wrapper, () => wrapper.find('h3').text() === updatedTitle);
+
+ expect(wrapper.find('h3').text()).toBe(updatedTitle);
+ expect(wrapper.find('p').text()).toBe(updatedContent);
+ });
+});
diff --git a/packages/vue/src/__tests__/useLoading.web.ts b/packages/vue/src/__tests__/useLoading.web.ts
new file mode 100644
index 000000000000..2eaa5d620001
--- /dev/null
+++ b/packages/vue/src/__tests__/useLoading.web.ts
@@ -0,0 +1,238 @@
+import { mount } from '@vue/test-utils';
+import { defineComponent, h, nextTick } from 'vue';
+
+import useLoading from '../consumers/useLoading';
+
+describe('vue useLoading()', () => {
+ async function flushUntil(
+ wrapper: any,
+ predicate: () => boolean,
+ tries = 100,
+ ) {
+ for (let i = 0; i < tries; i++) {
+ if (predicate()) return;
+ await Promise.resolve();
+ await nextTick();
+ await new Promise(resolve => setTimeout(resolve, 0));
+ }
+ }
+
+ it('tracks loading state for async functions', async () => {
+ let resolvePromise: (value: string) => void;
+ const mockAsyncFunction = jest.fn(
+ () =>
+ new Promise(resolve => {
+ resolvePromise = resolve;
+ }),
+ );
+
+ const TestComponent = defineComponent({
+ name: 'TestComponent',
+ setup() {
+ const [wrappedFunction, loading, error] = useLoading(mockAsyncFunction);
+
+ const handleClick = async () => {
+ await wrappedFunction();
+ };
+
+ return () =>
+ h('div', [
+ h(
+ 'button',
+ {
+ onClick: handleClick,
+ 'data-testid': 'test-button',
+ },
+ loading.value ? 'Loading...' : 'Click me',
+ ),
+ h('div', { 'data-testid': 'error' }, error.value?.message || ''),
+ h('div', { 'data-testid': 'loading' }, loading.value.toString()),
+ ]);
+ },
+ });
+
+ const wrapper = mount(TestComponent);
+
+ // Initially not loading
+ expect(wrapper.find('[data-testid="loading"]').text()).toBe('false');
+ expect(wrapper.find('[data-testid="test-button"]').text()).toBe('Click me');
+
+ // Click to trigger loading
+ await wrapper.find('[data-testid="test-button"]').trigger('click');
+ await nextTick();
+
+ // Should be loading
+ expect(wrapper.find('[data-testid="loading"]').text()).toBe('true');
+ expect(wrapper.find('[data-testid="test-button"]').text()).toBe(
+ 'Loading...',
+ );
+
+ // Resolve the promise
+ resolvePromise!('success');
+ await flushUntil(
+ wrapper,
+ () => wrapper.find('[data-testid="loading"]').text() === 'false',
+ );
+
+ // Should not be loading anymore
+ expect(wrapper.find('[data-testid="loading"]').text()).toBe('false');
+ expect(wrapper.find('[data-testid="test-button"]').text()).toBe('Click me');
+ expect(mockAsyncFunction).toHaveBeenCalledTimes(1);
+ });
+
+ it('handles errors from async functions', async () => {
+ const testError = new Error('Test error');
+ const mockAsyncFunction = jest.fn(() => Promise.reject(testError));
+
+ const TestComponent = defineComponent({
+ name: 'TestComponent',
+ setup() {
+ const [wrappedFunction, loading, error] = useLoading(mockAsyncFunction);
+
+ const handleClick = async () => {
+ await wrappedFunction();
+ };
+
+ return () =>
+ h('div', [
+ h(
+ 'button',
+ {
+ onClick: handleClick,
+ 'data-testid': 'test-button',
+ },
+ 'Click me',
+ ),
+ h('div', { 'data-testid': 'error' }, error.value?.message || ''),
+ h('div', { 'data-testid': 'loading' }, loading.value.toString()),
+ ]);
+ },
+ });
+
+ const wrapper = mount(TestComponent);
+
+ // Click to trigger error
+ await wrapper.find('[data-testid="test-button"]').trigger('click');
+ await flushUntil(
+ wrapper,
+ () => wrapper.find('[data-testid="error"]').text() === 'Test error',
+ );
+
+ // Should capture the error
+ expect(wrapper.find('[data-testid="error"]').text()).toBe('Test error');
+ expect(wrapper.find('[data-testid="loading"]').text()).toBe('false');
+ expect(mockAsyncFunction).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not update loading state after component unmount', async () => {
+ let resolvePromise: (value: string) => void;
+ const mockAsyncFunction = jest.fn(
+ () =>
+ new Promise(resolve => {
+ resolvePromise = resolve;
+ }),
+ );
+
+ const TestComponent = defineComponent({
+ name: 'TestComponent',
+ setup() {
+ const [wrappedFunction, loading] = useLoading(mockAsyncFunction);
+
+ const handleClick = async () => {
+ await wrappedFunction();
+ };
+
+ return () =>
+ h('div', [
+ h(
+ 'button',
+ {
+ onClick: handleClick,
+ 'data-testid': 'test-button',
+ },
+ 'Click me',
+ ),
+ h('div', { 'data-testid': 'loading' }, loading.value.toString()),
+ ]);
+ },
+ });
+
+ const wrapper = mount(TestComponent);
+
+ // Click to trigger loading
+ await wrapper.find('[data-testid="test-button"]').trigger('click');
+ await nextTick();
+
+ // Should be loading
+ expect(wrapper.find('[data-testid="loading"]').text()).toBe('true');
+
+ // Unmount component before resolving
+ wrapper.unmount();
+
+ // Resolve the promise after unmount
+ resolvePromise!('success');
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ // Component should be unmounted, no errors should occur
+ expect(mockAsyncFunction).toHaveBeenCalledTimes(1);
+ });
+
+ it('clears error on subsequent calls', async () => {
+ const testError = new Error('Test error');
+ let shouldError = true;
+ const mockAsyncFunction = jest.fn(() => {
+ if (shouldError) {
+ return Promise.reject(testError);
+ }
+ return Promise.resolve('success');
+ });
+
+ const TestComponent = defineComponent({
+ name: 'TestComponent',
+ setup() {
+ const [wrappedFunction, loading, error] = useLoading(mockAsyncFunction);
+
+ const handleClick = async () => {
+ await wrappedFunction();
+ };
+
+ return () =>
+ h('div', [
+ h(
+ 'button',
+ {
+ onClick: handleClick,
+ 'data-testid': 'test-button',
+ },
+ 'Click me',
+ ),
+ h('div', { 'data-testid': 'error' }, error.value?.message || ''),
+ h('div', { 'data-testid': 'loading' }, loading.value.toString()),
+ ]);
+ },
+ });
+
+ const wrapper = mount(TestComponent);
+
+ // First call - should error
+ await wrapper.find('[data-testid="test-button"]').trigger('click');
+ await flushUntil(
+ wrapper,
+ () => wrapper.find('[data-testid="error"]').text() === 'Test error',
+ );
+
+ expect(wrapper.find('[data-testid="error"]').text()).toBe('Test error');
+
+ // Second call - should succeed and clear error
+ shouldError = false;
+ await wrapper.find('[data-testid="test-button"]').trigger('click');
+ await flushUntil(
+ wrapper,
+ () => wrapper.find('[data-testid="error"]').text() === '',
+ );
+
+ expect(wrapper.find('[data-testid="error"]').text()).toBe('');
+ expect(wrapper.find('[data-testid="loading"]').text()).toBe('false');
+ expect(mockAsyncFunction).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/packages/vue/src/__tests__/useQuery.web.ts b/packages/vue/src/__tests__/useQuery.web.ts
new file mode 100644
index 000000000000..edb2bd136647
--- /dev/null
+++ b/packages/vue/src/__tests__/useQuery.web.ts
@@ -0,0 +1,207 @@
+import { schema } from '@data-client/endpoint';
+import { resource } from '@data-client/rest';
+import { mount } from '@vue/test-utils';
+import { defineComponent, h, nextTick } from 'vue';
+
+import {
+ ArticleWithSlug,
+ ArticleSlugResource,
+ ArticleResource,
+ IDEntity,
+} from '../../../../__tests__/new';
+import useQuery from '../consumers/useQuery';
+import { provideDataClient } from '../providers/provideDataClient';
+
+// Inline fixtures (duplicated from React tests to avoid cross-project imports)
+const payloadSlug = {
+ id: 5,
+ title: 'hi ho',
+ slug: 'hi-ho',
+ content: 'whatever',
+ tags: ['a', 'best', 'react'],
+};
+const nested = [
+ {
+ id: 5,
+ title: 'hi ho',
+ content: 'whatever',
+ tags: ['a', 'best', 'react'],
+ author: {
+ id: 23,
+ username: 'bob',
+ },
+ },
+ {
+ id: 3,
+ title: 'the next time',
+ content: 'whatever',
+ author: {
+ id: 23,
+ username: 'charles',
+ email: 'bob@bob.com',
+ },
+ },
+];
+
+describe('vue useQuery()', () => {
+ async function flush() {
+ await Promise.resolve();
+ await nextTick();
+ }
+
+ const ProvideWrapper = defineComponent({
+ name: 'ProvideWrapper',
+ setup(_props, { slots, expose }) {
+ const { controller } = provideDataClient();
+ expose({ controller });
+ return () => (slots.default ? slots.default() : null);
+ },
+ });
+
+ it('returns undefined with empty state', async () => {
+ const Inner = defineComponent({
+ setup() {
+ const val = useQuery(ArticleWithSlug, { id: payloadSlug.id });
+ return () => h('div', (val.value as any)?.title || '');
+ },
+ });
+
+ const wrapper = mount(ProvideWrapper, {
+ slots: { default: () => h(Inner) },
+ });
+ await flush();
+ expect(wrapper.text()).toBe('');
+ });
+
+ it('finds Entity by pk/slug after setResponse', async () => {
+ const Inner = defineComponent({
+ setup() {
+ const byId = useQuery(ArticleWithSlug, { id: payloadSlug.id });
+ const bySlug = useQuery(ArticleWithSlug, { slug: payloadSlug.slug });
+ return () =>
+ h(
+ 'div',
+ `${(byId.value as any)?.title || ''}|${
+ (bySlug.value as any)?.title || ''
+ }`,
+ );
+ },
+ });
+
+ const wrapper = mount(ProvideWrapper, {
+ slots: { default: () => h(Inner) },
+ });
+ const { controller }: any = wrapper.vm as any;
+
+ // seed data via controller
+ controller.setResponse(
+ ArticleSlugResource.get,
+ { id: payloadSlug.id },
+ payloadSlug,
+ );
+ await flush();
+
+ expect(wrapper.text()).toContain(payloadSlug.title);
+ });
+
+ it('selects Collections and updates when pushed', async () => {
+ const ListComp = defineComponent({
+ setup() {
+ const list = useQuery(ArticleResource.getList.schema);
+ return () =>
+ h('div', (list.value || []).map((a: any) => a.id).join(','));
+ },
+ });
+
+ const wrapper = mount(ProvideWrapper, {
+ slots: { default: () => h(ListComp) },
+ });
+ const { controller }: any = wrapper.vm as any;
+
+ controller.setResponse(ArticleResource.getList, {}, nested);
+ await flush();
+ expect(wrapper.text().split(',').filter(Boolean).length).toBe(
+ nested.length,
+ );
+
+ // Simulate push by setting new list value
+ const appended = nested.concat({
+ id: 50,
+ title: 'new',
+ content: 'x',
+ } as any);
+ controller.setResponse(ArticleResource.getList, {}, appended);
+ await flush();
+ expect(wrapper.text().split(',').filter(Boolean).length).toBe(
+ nested.length + 1,
+ );
+ });
+
+ it('retrieves a nested collection (Collection of Array)', async () => {
+ class Todo extends IDEntity {
+ userId = 0;
+ title = '';
+ completed = false;
+ static key = 'Todo';
+ }
+
+ class User extends IDEntity {
+ name = '';
+ username = '';
+ email = '';
+ todos: Todo[] = [];
+ static key = 'User';
+ static schema = {
+ todos: new schema.Collection(new schema.Array(Todo), {
+ nestKey: (parent: any) => ({ userId: parent.id }),
+ }),
+ };
+ }
+
+ const userTodos = new schema.Collection(new schema.Array(Todo), {
+ argsKey: ({ userId }: { userId: string }) => ({ userId }),
+ });
+
+ const UserResource = resource({ schema: User, path: '/users/:id' });
+
+ const Inner = defineComponent({
+ setup() {
+ const todos = useQuery(userTodos, { userId: '1' });
+ return () => h('div', (todos.value || []).length.toString());
+ },
+ });
+
+ const wrapper = mount(ProvideWrapper, {
+ slots: { default: () => h(Inner) },
+ });
+ const { controller }: any = wrapper.vm as any;
+
+ controller.setResponse(
+ UserResource.get,
+ { id: '1' },
+ {
+ id: '1',
+ todos: [{ id: '5', title: 'finish collections', userId: '1' }],
+ username: 'bob',
+ },
+ );
+ await flush();
+
+ expect(wrapper.text()).toBe('1');
+ });
+
+ it('works with unions collections (sanity)', async () => {
+ // Keep this light: verify we can call useQuery on a list schema and get an array
+ const list = useQuery(ArticleResource.getList.schema);
+ const Comp = defineComponent({
+ setup: () => () => h('div', (list.value || []).length),
+ });
+ const wrapper = mount(ProvideWrapper, {
+ slots: { default: () => h(Comp) },
+ });
+ const { controller }: any = wrapper.vm as any;
+ controller.setResponse(ArticleResource.getList, {}, []);
+ await flush();
+ expect(wrapper.text()).toBe('0');
+ });
+});
diff --git a/packages/vue/src/__tests__/useSubscription.web.ts b/packages/vue/src/__tests__/useSubscription.web.ts
new file mode 100644
index 000000000000..90ac23a893d8
--- /dev/null
+++ b/packages/vue/src/__tests__/useSubscription.web.ts
@@ -0,0 +1,151 @@
+import { mount } from '@vue/test-utils';
+import nock from 'nock';
+import { defineComponent, h, nextTick, Suspense } from 'vue';
+
+// Endpoints/entities from React subscriptions test
+import {
+ PollingArticleResource,
+ ArticleResource,
+} from '../../../../__tests__/new';
+import useSubscription from '../consumers/useSubscription';
+import useSuspense from '../consumers/useSuspense';
+import { provideDataClient } from '../providers/provideDataClient';
+
+describe('vue useSubscription()', () => {
+ const payload = {
+ id: 5,
+ title: 'hi ho',
+ content: 'whatever',
+ tags: ['a', 'best', 'react'],
+ };
+
+ // Mutable payload to simulate server-side updates with polling
+ let currentPollingPayload: typeof payload = { ...payload };
+
+ async function flushUntil(
+ wrapper: any,
+ predicate: () => boolean,
+ tries = 100,
+ ) {
+ for (let i = 0; i < tries; i++) {
+ if (predicate()) return;
+ await Promise.resolve();
+ await nextTick();
+ await new Promise(resolve => setTimeout(resolve, 0));
+ }
+ }
+
+ beforeAll(() => {
+ // Global network stubs reused by tests
+ nock(/.*/)
+ .persist()
+ .defaultReplyHeaders({
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Headers': 'Access-Token',
+ 'Content-Type': 'application/json',
+ })
+ .options(/.*/)
+ .reply(200)
+ // ArticleResource and PollingArticleResource both hit /article/:id
+ .get(`/article/${payload.id}`)
+ .reply(200, () => currentPollingPayload)
+ .put(`/article/${payload.id}`)
+ .reply(200, (uri, requestBody: any) => ({
+ ...currentPollingPayload,
+ ...requestBody,
+ }));
+ });
+
+ afterAll(() => {
+ nock.cleanAll();
+ });
+
+ const ProvideWrapper = defineComponent({
+ name: 'ProvideWrapper',
+ setup(_props, { slots, expose }) {
+ const { controller } = provideDataClient();
+ expose({ controller });
+ return () =>
+ h(
+ Suspense,
+ {},
+ {
+ default: () => (slots.default ? slots.default() : null),
+ fallback: () => h('div', { class: 'fallback' }, 'Loading'),
+ },
+ );
+ },
+ });
+
+ const ArticleComp = defineComponent({
+ name: 'ArticleComp',
+ props: { active: { type: Boolean, default: true } },
+ async setup(props) {
+ // Subscribe BEFORE any await to preserve current instance for inject()
+ useSubscription(
+ PollingArticleResource.get,
+ props.active ? { id: payload.id } : (null as any),
+ );
+ const article = await useSuspense(PollingArticleResource.get, {
+ id: payload.id,
+ });
+ return () =>
+ h('div', [
+ h('h3', (article as any).value.title),
+ h('p', (article as any).value.content),
+ ]);
+ },
+ });
+
+ it('subscribes and re-renders on updates (simulated poll)', async () => {
+ currentPollingPayload = { ...payload };
+
+ const wrapper = mount(ProvideWrapper, {
+ slots: { default: () => h(ArticleComp, { active: true }) },
+ });
+
+ // Initially should render fallback while Suspense is pending
+ expect(wrapper.find('.fallback').exists()).toBe(true);
+
+ // Flush initial fetch
+ await flushUntil(wrapper, () => wrapper.find('h3').exists());
+
+ // Verify initial values
+ expect(wrapper.find('h3').text()).toBe(payload.title);
+ expect(wrapper.find('p').text()).toBe(payload.content);
+
+ // Simulate a polling update by changing server payload and manually fetching
+ const updatedTitle = payload.title + ' fiver';
+ currentPollingPayload = { ...payload, title: updatedTitle } as any;
+ const exposed: any = wrapper.vm as any;
+ await exposed.controller.fetch(PollingArticleResource.get, {
+ id: payload.id,
+ });
+
+ await flushUntil(wrapper, () => wrapper.find('h3').text() === updatedTitle);
+ expect(wrapper.find('h3').text()).toBe(updatedTitle);
+ });
+
+ it('can subscribe to endpoint without pollFrequency (no-op) and render', async () => {
+ // Minimal component that subscribes to non-polling endpoint
+ const NoFreqComp = defineComponent({
+ name: 'NoFreqComp',
+ async setup() {
+ // Subscribe first (no poller attached)
+ useSubscription(ArticleResource.get, { id: payload.id } as any);
+ // Then resolve suspense for stable render
+ const article = await useSuspense(ArticleResource.get, {
+ id: payload.id,
+ });
+ return () => h('div', (article as any).value.title);
+ },
+ });
+
+ const wrapper = mount(ProvideWrapper, {
+ slots: { default: () => h(NoFreqComp) },
+ });
+
+ await flushUntil(wrapper, () => wrapper.text() !== '');
+ expect(wrapper.text()).not.toEqual('');
+ });
+});
diff --git a/packages/vue/src/__tests__/useSuspense.web.ts b/packages/vue/src/__tests__/useSuspense.web.ts
new file mode 100644
index 000000000000..44a40fb04417
--- /dev/null
+++ b/packages/vue/src/__tests__/useSuspense.web.ts
@@ -0,0 +1,159 @@
+import { mount } from '@vue/test-utils';
+import nock from 'nock';
+import { defineComponent, h, nextTick, Suspense } from 'vue';
+
+// Reuse the same endpoints/fixtures used by the React tests
+import { CoolerArticleResource } from '../../../../__tests__/new';
+// Minimal shared fixture (copied from React test fixtures)
+const payload = {
+ id: 5,
+ title: 'hi ho',
+ content: 'whatever',
+ tags: ['a', 'best', 'react'],
+};
+import useSuspense from '../consumers/useSuspense';
+import { provideDataClient } from '../providers/provideDataClient';
+
+describe('vue useSuspense()', () => {
+ async function flushUntil(
+ wrapper: any,
+ predicate: () => boolean,
+ tries = 100,
+ ) {
+ for (let i = 0; i < tries; i++) {
+ if (predicate()) return;
+ await Promise.resolve();
+ await nextTick();
+ await new Promise(resolve => setTimeout(resolve, 0));
+ }
+ }
+
+ beforeAll(() => {
+ nock(/.*/)
+ .persist()
+ .defaultReplyHeaders({
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Headers': 'Access-Token',
+ 'Content-Type': 'application/json',
+ })
+ .options(/.*/)
+ .reply(200)
+ .get(`/article-cooler/${payload.id}`)
+ .reply(200, payload)
+ .put(`/article-cooler/${payload.id}`)
+ .reply(200, (uri, requestBody: any) => ({
+ ...payload,
+ ...requestBody,
+ }));
+ });
+
+ afterAll(() => {
+ nock.cleanAll();
+ });
+
+ const ArticleComp = defineComponent({
+ name: 'ArticleComp',
+ async setup() {
+ const article = await useSuspense(CoolerArticleResource.get, {
+ id: payload.id,
+ });
+ return () =>
+ h('div', [
+ h('h3', (article as any).value.title),
+ h('p', (article as any).value.content),
+ ]);
+ },
+ });
+
+ const ProvideWrapper = defineComponent({
+ name: 'ProvideWrapper',
+ setup(_props, { slots, expose }) {
+ const { controller } = provideDataClient();
+ expose({ controller });
+ return () =>
+ h(
+ Suspense,
+ {},
+ {
+ default: () => (slots.default ? slots.default() : h(ArticleComp)),
+ fallback: () => h('div', { class: 'fallback' }, 'Loading'),
+ },
+ );
+ },
+ });
+
+ it('suspends on empty store, then renders after fetch resolves', async () => {
+ const wrapper = mount(ProvideWrapper, {
+ slots: { default: () => h(ArticleComp) },
+ });
+
+ // Initially should render fallback while Suspense is pending
+ expect(wrapper.find('.fallback').exists()).toBe(true);
+
+ // Flush pending promises/ticks until content renders
+ await flushUntil(wrapper, () => wrapper.find('h3').exists());
+
+ const title = wrapper.find('h3');
+ const content = wrapper.find('p');
+ expect(title.exists()).toBe(true);
+ expect(content.exists()).toBe(true);
+ expect(title.text()).toBe(payload.title);
+ expect(content.text()).toBe(payload.content);
+ });
+
+ it('re-renders when controller.setResponse() updates data', async () => {
+ const wrapper = mount(ProvideWrapper, {
+ slots: { default: () => h(ArticleComp) },
+ });
+ // Wait for initial render
+ await flushUntil(wrapper, () => wrapper.find('h3').exists());
+
+ // Verify initial values
+ expect(wrapper.find('h3').text()).toBe(payload.title);
+ expect(wrapper.find('p').text()).toBe(payload.content);
+
+ // Update the store using controller.setResponse
+ const exposed: any = wrapper.vm as any;
+ const newTitle = payload.title + ' updated';
+ const newContent = (payload as any).content + ' v2';
+ exposed.controller.setResponse(
+ CoolerArticleResource.get,
+ { id: payload.id },
+ { ...payload, title: newTitle, content: newContent },
+ );
+
+ await flushUntil(wrapper, () => wrapper.find('h3').text() === newTitle);
+
+ expect(wrapper.find('h3').text()).toBe(newTitle);
+ expect(wrapper.find('p').text()).toBe(newContent);
+ });
+
+ it('re-renders when controller.fetch() mutates data', async () => {
+ const wrapper = mount(ProvideWrapper, {
+ slots: { default: () => h(ArticleComp) },
+ });
+ // Wait for initial render
+ await flushUntil(wrapper, () => wrapper.find('h3').exists());
+
+ // Verify initial values
+ expect(wrapper.find('h3').text()).toBe(payload.title);
+ expect(wrapper.find('p').text()).toBe(payload.content);
+
+ // Mutate the data using controller.fetch with update endpoint
+ const exposed: any = wrapper.vm as any;
+ const updatedTitle = payload.title + ' mutated';
+ const updatedContent = payload.content + ' mutated';
+
+ await exposed.controller.fetch(
+ CoolerArticleResource.update,
+ { id: payload.id },
+ { title: updatedTitle, content: updatedContent },
+ );
+
+ // Wait for re-render with new data
+ await flushUntil(wrapper, () => wrapper.find('h3').text() === updatedTitle);
+
+ expect(wrapper.find('h3').text()).toBe(updatedTitle);
+ expect(wrapper.find('p').text()).toBe(updatedContent);
+ });
+});
diff --git a/packages/vue/src/consumers/index.ts b/packages/vue/src/consumers/index.ts
new file mode 100644
index 000000000000..43b88740fe5e
--- /dev/null
+++ b/packages/vue/src/consumers/index.ts
@@ -0,0 +1,8 @@
+export { default as useSuspense } from './useSuspense.js';
+export { default as useSubscription } from './useSubscription.js';
+export { default as useQuery } from './useQuery.js';
+export { default as useLive } from './useLive.js';
+export { default as useLoading } from './useLoading.js';
+export { default as useDebounce } from './useDebounce.js';
+export { default as useFetch } from './useFetch.js';
+export { useController as useController } from '../context.js';
diff --git a/packages/vue/src/consumers/useDebounce.ts b/packages/vue/src/consumers/useDebounce.ts
new file mode 100644
index 000000000000..6f501607540c
--- /dev/null
+++ b/packages/vue/src/consumers/useDebounce.ts
@@ -0,0 +1,78 @@
+import { ref, watch, onUnmounted, type Ref } from 'vue';
+
+/**
+ * Keeps value updated after delay time
+ *
+ * @see https://dataclient.io/docs/api/useDebounce
+ * @param value Any immutable value (can be a ref)
+ * @param delay Time in milliseconds to wait til updating the value
+ * @param updatable Whether to update at all
+ * @example
+ ```
+ const [debouncedQuery, isPending] = useDebounce(query, 200);
+ const list = useSuspense(getThings, { query: debouncedQuery.value });
+ ```
+ */
+export default function useDebounce(
+ value: T | Ref,
+ delay: number,
+ updatable: boolean | Ref = true,
+): [Ref, Ref] {
+ const debouncedValue = ref(
+ typeof value === 'object' && value !== null && 'value' in value ?
+ value.value
+ : value,
+ ) as Ref;
+ const isPending = ref(false);
+ let timeoutId: ReturnType | null = null;
+
+ const clearExistingTimeout = () => {
+ if (timeoutId !== null) {
+ clearTimeout(timeoutId);
+ timeoutId = null;
+ }
+ };
+
+ // Watch the input value and debounce updates
+ watch(
+ [
+ () =>
+ typeof value === 'object' && value !== null && 'value' in value ?
+ value.value
+ : value,
+ () =>
+ (
+ typeof updatable === 'object' &&
+ updatable !== null &&
+ 'value' in updatable
+ ) ?
+ updatable.value
+ : updatable,
+ () => delay,
+ ],
+ ([newValue, isUpdatable]) => {
+ clearExistingTimeout();
+
+ if (!isUpdatable) {
+ isPending.value = false;
+ return;
+ }
+
+ isPending.value = true;
+
+ timeoutId = setTimeout(() => {
+ debouncedValue.value = newValue;
+ isPending.value = false;
+ timeoutId = null;
+ }, delay);
+ },
+ { immediate: false },
+ );
+
+ // Cleanup on unmount
+ onUnmounted(() => {
+ clearExistingTimeout();
+ });
+
+ return [debouncedValue, isPending];
+}
diff --git a/packages/vue/src/consumers/useFetch.ts b/packages/vue/src/consumers/useFetch.ts
new file mode 100644
index 000000000000..92c47a53434d
--- /dev/null
+++ b/packages/vue/src/consumers/useFetch.ts
@@ -0,0 +1,80 @@
+import { ExpiryStatus } from '@data-client/core';
+import type {
+ EndpointInterface,
+ Denormalize,
+ Schema,
+ FetchFunction,
+ DenormalizeNullable,
+} from '@data-client/core';
+import { computed, watch } from 'vue';
+
+import { useController, injectState } from '../context.js';
+
+/**
+ * Fetch an Endpoint if it is not in cache or stale.
+ * Non-suspense; returns the fetch Promise when a request is issued, otherwise undefined.
+ * Mirrors React useFetch semantics.
+ * @see https://dataclient.io/docs/api/useFetch
+ */
+export default function useFetch<
+ E extends EndpointInterface<
+ FetchFunction,
+ Schema | undefined,
+ undefined | false
+ >,
+>(
+ endpoint: E,
+ ...args: readonly [...Parameters]
+): E['schema'] extends undefined | null ? ReturnType
+: Promise>;
+
+export default function useFetch<
+ E extends EndpointInterface<
+ FetchFunction,
+ Schema | undefined,
+ undefined | false
+ >,
+>(
+ endpoint: E,
+ ...args: readonly [...Parameters] | readonly [null]
+): E['schema'] extends undefined | null ? ReturnType | undefined
+: Promise> | undefined;
+
+export default function useFetch(endpoint: any, ...args: any[]): any {
+ const stateRef = injectState();
+ const controller = useController();
+
+ const key: string = args[0] !== null ? endpoint.key(...args) : '';
+
+ // Compute response meta reactively so we can respond to store updates
+ const responseMeta = computed(() =>
+ key ? controller.getResponseMeta(endpoint, ...args, stateRef.value) : null,
+ );
+
+ let lastPromise: Promise | undefined = undefined;
+
+ const maybeFetch = () => {
+ if (!key) return;
+ const meta = responseMeta.value;
+ if (!meta) return;
+ const forceFetch = meta.expiryStatus === ExpiryStatus.Invalid;
+ if (Date.now() <= meta.expiresAt && !forceFetch) return;
+ lastPromise = controller.fetch(endpoint, ...(args as any));
+ };
+
+ // Trigger on initial call
+ maybeFetch();
+
+ // Also watch for store changes that might require refetch (e.g., invalidation)
+ watch(
+ () => {
+ const m = responseMeta.value;
+ return m ? [m.expiresAt, m.expiryStatus, stateRef.value.lastReset] : key;
+ },
+ () => {
+ maybeFetch();
+ },
+ );
+
+ return lastPromise;
+}
diff --git a/packages/vue/src/consumers/useLive.ts b/packages/vue/src/consumers/useLive.ts
new file mode 100644
index 000000000000..b93df3148330
--- /dev/null
+++ b/packages/vue/src/consumers/useLive.ts
@@ -0,0 +1,64 @@
+import type {
+ EndpointInterface,
+ Denormalize,
+ Schema,
+ FetchFunction,
+ ResolveType,
+ DenormalizeNullable,
+} from '@data-client/core';
+import type { DeepReadonly, ComputedRef } from 'vue';
+
+import useSubscription from './useSubscription.js';
+import useSuspense from './useSuspense.js';
+
+/**
+ * Ensure an endpoint is available. Keeps it fresh once it is.
+ *
+ * useSuspense() + useSubscription()
+ * @see https://dataclient.io/docs/api/useLive
+ * @throws {Promise} If data is not yet available.
+ * @throws {NetworkError} If fetch fails.
+ */
+export default function useLive<
+ E extends EndpointInterface<
+ FetchFunction,
+ Schema | undefined,
+ undefined | false
+ >,
+>(
+ endpoint: E,
+ ...args: readonly [...Parameters]
+): Promise<
+ DeepReadonly<
+ ComputedRef<
+ E['schema'] extends undefined | null ? ResolveType
+ : Denormalize
+ >
+ >
+>;
+
+export default function useLive<
+ E extends EndpointInterface<
+ FetchFunction,
+ Schema | undefined,
+ undefined | false
+ >,
+>(
+ endpoint: E,
+ ...args: readonly [...Parameters] | readonly [null]
+): Promise<
+ DeepReadonly<
+ ComputedRef<
+ E['schema'] extends undefined | null ? ResolveType | undefined
+ : DenormalizeNullable
+ >
+ >
+>;
+
+export default async function useLive(
+ endpoint: any,
+ ...args: any[]
+): Promise {
+ useSubscription(endpoint, ...args);
+ return useSuspense(endpoint, ...args);
+}
diff --git a/packages/vue/src/consumers/useLoading.ts b/packages/vue/src/consumers/useLoading.ts
new file mode 100644
index 000000000000..61ca288cf280
--- /dev/null
+++ b/packages/vue/src/consumers/useLoading.ts
@@ -0,0 +1,47 @@
+import { ref, onUnmounted, type Ref } from 'vue';
+
+/**
+ * Takes an async function and tracks resolution as a boolean.
+ *
+ * @see https://dataclient.io/docs/api/useLoading
+ * @param func A function returning a promise
+ * @example
+ ```
+ function Button({ onClick, children, ...props }) {
+ const [clickHandler, loading] = useLoading(onClick);
+ return h('button', { onClick: clickHandler, ...props },
+ loading.value ? 'Loading...' : children
+ );
+ }
+ ```
+ */
+export default function useLoading Promise>(
+ func: F,
+): [F, Ref, Ref] {
+ const loading = ref(false);
+ const error = ref(undefined);
+ const isMounted = ref(true);
+
+ onUnmounted(() => {
+ isMounted.value = false;
+ });
+
+ // Create the wrapped function directly - no need for computed in Vue
+ const wrappedFunc = (async (...args: any) => {
+ loading.value = true;
+ error.value = undefined;
+ let ret;
+ try {
+ ret = await func(...args);
+ } catch (e: any) {
+ error.value = e;
+ } finally {
+ if (isMounted.value) {
+ loading.value = false;
+ }
+ }
+ return ret;
+ }) as F;
+
+ return [wrappedFunc, loading, error];
+}
diff --git a/packages/vue/src/consumers/useQuery.ts b/packages/vue/src/consumers/useQuery.ts
new file mode 100644
index 000000000000..dedddefbce34
--- /dev/null
+++ b/packages/vue/src/consumers/useQuery.ts
@@ -0,0 +1,43 @@
+import type {
+ DenormalizeNullable,
+ NI,
+ Queryable,
+ SchemaArgs,
+} from '@data-client/core';
+import { computed, watch, type ComputedRef } from 'vue';
+
+import { useController, injectState } from '../context.js';
+
+/**
+ * Query the store (non-suspense).
+ *
+ * Returns a readonly computed ref of the query result. The value is undefined when
+ * the result is not found or invalid.
+ * Mirrors React's useQuery semantics using Vue reactivity.
+ * @see https://dataclient.io/docs/api/useQuery
+ */
+export default function useQuery(
+ schema: S,
+ ...args: NI>
+): ComputedRef | undefined> {
+ const stateRef = injectState();
+ const controller = useController();
+
+ // Compute query meta based on state and args. This mirrors React's memoization
+ // that keys off state.entities/indexes and args.
+ const queryMeta = computed(() =>
+ controller.getQueryMeta(schema, ...args, stateRef.value as any),
+ );
+
+ // Maintain GC refcounts on data mount/changes
+ watch(
+ () => queryMeta.value.data,
+ (_newVal, _oldVal, onCleanup) => {
+ const decrement = queryMeta.value.countRef();
+ onCleanup(() => decrement());
+ },
+ { immediate: true },
+ );
+
+ return computed(() => queryMeta.value.data);
+}
diff --git a/packages/vue/src/consumers/useSubscription.ts b/packages/vue/src/consumers/useSubscription.ts
new file mode 100644
index 000000000000..46a2a39c2754
--- /dev/null
+++ b/packages/vue/src/consumers/useSubscription.ts
@@ -0,0 +1,44 @@
+import type {
+ EndpointInterface,
+ Schema,
+ FetchFunction,
+} from '@data-client/core';
+import { computed, unref, watch } from 'vue';
+
+import { useController } from '../context.js';
+
+/**
+ * Keeps a resource fresh by subscribing to updates.
+ * Mirrors React hook API. Pass `null` as first arg to unsubscribe.
+ * @see https://dataclient.io/docs/api/useSubscription
+ */
+export default function useSubscription<
+ E extends EndpointInterface<
+ FetchFunction,
+ Schema | undefined,
+ undefined | false
+ >,
+>(endpoint: E, ...args: readonly [...Parameters] | readonly [null]) {
+ const controller = useController();
+
+ // Track top-level reactive args (Refs are unwrapped). This allows props/refs to trigger resubscribe.
+ const resolvedArgs = computed(() => args.map(a => unref(a as any)) as any);
+ const key = computed(() => {
+ if (resolvedArgs.value[0] === null) return '';
+ return endpoint.key(...(resolvedArgs.value as readonly [...Parameters]));
+ });
+
+ // Subscribe when key exists; unsubscribe on change or unmount
+ watch(
+ key,
+ (_newKey, _oldKey, onCleanup) => {
+ if (!key.value) return;
+ const cleanedArgs = resolvedArgs.value as readonly [...Parameters];
+ controller.subscribe(endpoint, ...cleanedArgs);
+ onCleanup(() => {
+ controller.unsubscribe(endpoint, ...cleanedArgs);
+ });
+ },
+ { immediate: true },
+ );
+}
diff --git a/packages/vue/src/consumers/useSuspense.ts b/packages/vue/src/consumers/useSuspense.ts
new file mode 100644
index 000000000000..a21b079bc8b6
--- /dev/null
+++ b/packages/vue/src/consumers/useSuspense.ts
@@ -0,0 +1,91 @@
+import { ExpiryStatus } from '@data-client/core';
+import type {
+ EndpointInterface,
+ Denormalize,
+ Schema,
+ FetchFunction,
+ DenormalizeNullable,
+ ResolveType,
+} from '@data-client/core';
+import {
+ computed,
+ watch,
+ readonly,
+ type DeepReadonly,
+ type ComputedRef,
+} from 'vue';
+
+import { useController, injectState } from '../context.js';
+
+export default function useSuspense<
+ E extends EndpointInterface<
+ FetchFunction,
+ Schema | undefined,
+ undefined | false
+ >,
+>(
+ endpoint: E,
+ ...args: readonly [...Parameters]
+): Promise<
+ DeepReadonly<
+ ComputedRef<
+ E['schema'] extends undefined | null ? ResolveType
+ : Denormalize
+ >
+ >
+>;
+
+export default function useSuspense<
+ E extends EndpointInterface<
+ FetchFunction,
+ Schema | undefined,
+ undefined | false
+ >,
+>(
+ endpoint: E,
+ ...args: readonly [...Parameters] | readonly [null]
+): Promise<
+ DeepReadonly<
+ ComputedRef<
+ E['schema'] extends undefined | null ? ResolveType | undefined
+ : DenormalizeNullable
+ >
+ >
+>;
+
+export default async function useSuspense(
+ endpoint: any,
+ ...args: any[]
+): Promise {
+ const stateRef = injectState();
+ const controller = useController();
+
+ const key = args[0] !== null ? endpoint.key(...args) : '';
+ const responseMeta = computed(() =>
+ controller.getResponseMeta(endpoint, ...args, stateRef.value),
+ );
+
+ // If invalid or expired, perform fetch and await it to integrate with Vue Suspense
+ const isInvalid =
+ responseMeta.value.expiryStatus === ExpiryStatus.Invalid ||
+ Date.now() > responseMeta.value.expiresAt;
+ if (key && isInvalid) {
+ await controller.fetch(endpoint, ...(args as any));
+ }
+
+ const error = controller.getError(endpoint, ...args, stateRef.value);
+ if (error) throw error;
+
+ // Maintain GC refcounts on data mount/changes
+ watch(
+ () => responseMeta.value.data,
+ (_newVal, _oldVal, onCleanup) => {
+ const decrement = responseMeta.value.countRef();
+ onCleanup(() => decrement());
+ },
+ { immediate: true },
+ );
+
+ // Return readonly computed ref - Vue automatically unwraps in templates and reactive contexts
+ return readonly(computed(() => responseMeta.value.data));
+}
diff --git a/packages/vue/src/context.ts b/packages/vue/src/context.ts
new file mode 100644
index 000000000000..b1fd002df1c8
--- /dev/null
+++ b/packages/vue/src/context.ts
@@ -0,0 +1,42 @@
+import { initialState, Controller } from '@data-client/core';
+import type { State } from '@data-client/core';
+import { inject, type InjectionKey, shallowRef, type ShallowRef } from 'vue';
+
+export const StateKey: InjectionKey>> = Symbol(
+ 'DataClientState',
+) as any;
+export const ControllerKey: InjectionKey = Symbol(
+ 'DataClientController',
+) as any;
+
+/** Fallback state ref used when no provider is found. */
+export const FallbackStateRef: ShallowRef> =
+ shallowRef(initialState);
+
+export function useController(): Controller {
+ const ctrl = inject(ControllerKey, null);
+ if (!ctrl) {
+ if (process.env.NODE_ENV !== 'production') {
+ console.error(
+ 'It appears you are trying to use Reactive Data Client (Vue) without a provider.\n' +
+ 'Follow instructions: https://dataclient.io/docs/getting-started/installation#add-provider-at-top-level-component',
+ );
+ }
+ return new Controller();
+ }
+ return ctrl;
+}
+
+export function injectState(): ShallowRef> {
+ const state = inject(StateKey, null);
+ if (!state) {
+ if (process.env.NODE_ENV !== 'production') {
+ console.error(
+ 'It appears you are trying to use Reactive Data Client (Vue) without a provider.\n' +
+ 'Follow instructions: https://dataclient.io/docs/getting-started/installation#add-provider-at-top-level-component',
+ );
+ }
+ return FallbackStateRef;
+ }
+ return state;
+}
diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts
new file mode 100644
index 000000000000..4cf9842107d0
--- /dev/null
+++ b/packages/vue/src/index.ts
@@ -0,0 +1,45 @@
+export { Controller, ExpiryStatus, actionTypes } from '@data-client/core';
+export type {
+ EndpointExtraOptions,
+ FetchFunction,
+ ResolveType,
+ EndpointInterface,
+ EntityInterface,
+ Queryable,
+ SchemaArgs,
+ Schema,
+ SchemaClass,
+ DenormalizeNullable,
+ Denormalize,
+ Normalize,
+ NormalizeNullable,
+ FetchAction,
+ InvalidateAction,
+ UnsubscribeAction,
+ SubscribeAction,
+ ResetAction,
+ SetAction,
+ SetResponseAction,
+ ActionTypes,
+ NetworkError,
+ UnknownError,
+ ErrorTypes,
+ AbstractInstanceType,
+ UpdateFunction,
+ State,
+ PK,
+ Dispatch,
+ Middleware,
+ MiddlewareAPI,
+ Manager,
+ GCInterface,
+ GCOptions,
+ CreateCountRef,
+ // used in Controller generic
+ DataClientDispatch,
+ GenericDispatch,
+} from '@data-client/core';
+
+export * from './consumers/index.js';
+export * from './providers/index.js';
+export * from './managers/index.js';
diff --git a/packages/vue/src/managers/index.ts b/packages/vue/src/managers/index.ts
new file mode 100644
index 000000000000..a6a3c059d116
--- /dev/null
+++ b/packages/vue/src/managers/index.ts
@@ -0,0 +1,8 @@
+export {
+ PollingSubscription,
+ DevToolsManager,
+ SubscriptionManager,
+ DefaultConnectionListener,
+ LogoutManager,
+ NetworkManager,
+} from '@data-client/core';
diff --git a/packages/vue/src/providers/getDefaultManagers.ts b/packages/vue/src/providers/getDefaultManagers.ts
new file mode 100644
index 000000000000..b8d7953eb63b
--- /dev/null
+++ b/packages/vue/src/providers/getDefaultManagers.ts
@@ -0,0 +1,80 @@
+import type { DevToolsConfig, Manager } from '@data-client/core';
+import {
+ NetworkManager,
+ SubscriptionManager,
+ PollingSubscription,
+ DevToolsManager,
+} from '@data-client/core';
+
+/* istanbul ignore next */
+/** Returns the default Managers used by DataProvider. */
+let getDefaultManagers: (options?: GetManagersOptions) => Manager[] = (
+ options = {},
+) => {
+ const { networkManager, subscriptionManager = PollingSubscription } = options;
+ if (subscriptionManager === null) {
+ return [constructManager(NetworkManager, networkManager ?? ({} as any))];
+ }
+ return [
+ constructManager(NetworkManager, networkManager ?? ({} as any)),
+ constructManager(SubscriptionManager, subscriptionManager),
+ ];
+};
+/* istanbul ignore else */
+if (process.env.NODE_ENV !== 'production') {
+ getDefaultManagers = (options: GetManagersOptions = {}): Manager[] => {
+ const {
+ devToolsManager,
+ networkManager,
+ subscriptionManager = PollingSubscription,
+ } = options;
+ if (networkManager === null) {
+ console.error('Disabling NetworkManager is not allowed.');
+ // fall back to default options
+ }
+ const nm = constructManager(NetworkManager, networkManager ?? ({} as any));
+ const managers: Manager[] = [nm];
+ if (subscriptionManager !== null) {
+ managers.push(constructManager(SubscriptionManager, subscriptionManager));
+ }
+ if (devToolsManager !== null) {
+ let dtm: DevToolsManager;
+ if (devToolsManager instanceof DevToolsManager) {
+ dtm = devToolsManager;
+ } else {
+ dtm = new DevToolsManager(
+ devToolsManager as any,
+ nm.skipLogging.bind(nm),
+ );
+ }
+ managers.unshift(dtm);
+ }
+ return managers;
+ };
+}
+export { getDefaultManagers };
+
+function constructManager(
+ Mgr: M,
+ optionOrInstance: InstanceType | ConstructorArgs,
+): InstanceType {
+ if (optionOrInstance instanceof Mgr) {
+ return optionOrInstance as InstanceType;
+ }
+ return new Mgr(optionOrInstance as any) as InstanceType;
+}
+
+export type GetManagersOptions = {
+ devToolsManager?: DevToolsManager | DevToolsConfig | null;
+ networkManager?:
+ | NetworkManager
+ | ConstructorArgs
+ | null;
+ subscriptionManager?:
+ | SubscriptionManager
+ | ConstructorArgs
+ | null;
+};
+
+export type ConstructorArgs =
+ T extends new (options: infer O) => any ? O : never;
diff --git a/packages/vue/src/providers/index.ts b/packages/vue/src/providers/index.ts
new file mode 100644
index 000000000000..e9048820e9ed
--- /dev/null
+++ b/packages/vue/src/providers/index.ts
@@ -0,0 +1,2 @@
+export { getDefaultManagers } from './getDefaultManagers.js';
+export { provideDataClient } from '../providers/provideDataClient.js';
diff --git a/packages/vue/src/providers/provideDataClient.ts b/packages/vue/src/providers/provideDataClient.ts
new file mode 100644
index 000000000000..4784ce0487c3
--- /dev/null
+++ b/packages/vue/src/providers/provideDataClient.ts
@@ -0,0 +1,126 @@
+import {
+ initialState as defaultState,
+ Controller as DataController,
+ applyManager,
+ initManager,
+ createReducer,
+} from '@data-client/core';
+import type { State, Manager, GCInterface } from '@data-client/core';
+import {
+ onMounted,
+ onUnmounted,
+ provide,
+ shallowRef,
+ type ShallowRef,
+} from 'vue';
+
+import { ControllerKey, StateKey } from '../context.js';
+import { getDefaultManagers } from './getDefaultManagers.js';
+
+export interface ProvideOptions {
+ managers?: Manager[];
+ initialState?: State;
+ Controller?: typeof DataController;
+ gcPolicy?: GCInterface;
+}
+
+export interface ProvidedDataClient {
+ controller: InstanceType;
+ /** Optimistic overlay state ref provided to consumers */
+ stateRef: ShallowRef>;
+}
+
+/**
+ * Provide/inject setup for @data-client/vue. Call inside setup() of your root component.
+ * Mirrors React DataProvider but as a composable.
+ */
+export function provideDataClient(
+ options: ProvideOptions = {},
+): ProvidedDataClient {
+ const { Controller = DataController, gcPolicy } = options;
+
+ // stable singletons for this provider scope
+ const controller = new Controller({ gcPolicy });
+ const managers = options.managers ?? getDefaultManagers();
+ const baseInitial = options.initialState ?? (defaultState as State);
+
+ // init managers (run on mount/unmount)
+ const mgrEffect = initManager(managers, controller, baseInitial);
+
+ // build middlewares and bind to controller
+ const middlewares = applyManager(managers, controller);
+
+ // reducer and state management
+ const reducer = createReducer(controller);
+
+ // base state (no optimistic overlay) exposed to controller.getState via middleware API
+ const baseStateRef = shallowRef>(baseInitial);
+ // optimistic/effective state provided to consumers
+ const stateRef = shallowRef>(computeOptimistic(baseInitial));
+
+ type Dispatch = (action: any) => Promise;
+
+ let resolveNext: (() => void) | null = null;
+ const waitForCommit = () =>
+ new Promise(resolve => {
+ resolveNext = resolve;
+ });
+
+ const realDispatch: Dispatch = async action => {
+ const nextBase = reducer(baseStateRef.value, action);
+ baseStateRef.value = nextBase;
+ stateRef.value = computeOptimistic(nextBase);
+ if (resolveNext) {
+ const r = resolveNext;
+ resolveNext = null;
+ r();
+ }
+ };
+
+ const getState = () => baseStateRef.value;
+
+ // compose middlewares similar to redux
+ const chain = middlewares.map(mw =>
+ mw({
+ getState,
+ dispatch: (a: any) => outerDispatch(a),
+ } as any),
+ );
+ const compose = (fns: ((arg: any) => any)[]) => (initial: any) =>
+ fns.reduceRight((v, f) => f(v), initial);
+ const outerDispatch = compose(chain)(async (action: any) => {
+ const promise = waitForCommit();
+ await realDispatch(action);
+ return promise;
+ });
+
+ // wire controller
+ controller.bindMiddleware({
+ getState,
+ dispatch: (a: any) => outerDispatch(a),
+ } as any);
+
+ // provide to children
+ provide(StateKey, stateRef);
+ provide(ControllerKey, controller as any);
+
+ // run managers after mount and cleanup on unmount
+ let cleanup: void | (() => void);
+ onMounted(() => {
+ cleanup = mgrEffect();
+ });
+ onUnmounted(() => {
+ if (cleanup) cleanup();
+ });
+
+ return { controller: controller as any, stateRef };
+
+ function computeOptimistic(state: State): State {
+ // mirror Reactβs optimistic overlay
+ // reduce over pending optimistic actions to derive the effective state
+ // reducer is stable from closure
+ return state.optimistic.reduce(reducer as any, state);
+ }
+}
+
+export default provideDataClient;
diff --git a/packages/vue/tsconfig.compile.json b/packages/vue/tsconfig.compile.json
new file mode 100644
index 000000000000..49d8cca7e64b
--- /dev/null
+++ b/packages/vue/tsconfig.compile.json
@@ -0,0 +1,4 @@
+{
+ "extends": "./tsconfig",
+ "exclude": ["node_modules", "dist", "lib", "ts3.4", "src-legacy-types", "**/__tests__"]
+}
\ No newline at end of file
diff --git a/packages/vue/tsconfig.json b/packages/vue/tsconfig.json
new file mode 100644
index 000000000000..4086f3cf8201
--- /dev/null
+++ b/packages/vue/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "extends": "../../tsconfig-base",
+ "compilerOptions": {
+ "outDir": "lib",
+ "rootDir": "src"
+ },
+ "include": ["src"],
+ "references": [
+ { "path": "../../__tests__" },
+ { "path": "../core" }
+ ]
+}
\ No newline at end of file
diff --git a/packages/vue/typescript.svg b/packages/vue/typescript.svg
new file mode 100644
index 000000000000..52748150d26a
--- /dev/null
+++ b/packages/vue/typescript.svg
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/tsconfig.json b/tsconfig.json
index 6eb97ba2b448..d5e7705498e1 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -9,6 +9,7 @@
{ "path": "./packages/graphql/tsconfig.compile.json" },
{ "path": "./packages/core/tsconfig.compile.json" },
{ "path": "./packages/react/tsconfig.compile.json" },
+ { "path": "./packages/vue/tsconfig.compile.json" },
{ "path": "./packages/img/tsconfig.compile.json" },
{ "path": "./packages/test/tsconfig.compile.json" },
]
diff --git a/yarn.lock b/yarn.lock
index ee8c87f484ab..0dcb85790570 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3201,6 +3201,17 @@ __metadata:
languageName: unknown
linkType: soft
+"@data-client/core@npm:^0.15.0-beta-0":
+ version: 0.15.0-beta-20251006024044-92bd01c4976f2921993b8c9f1e4dbb87af87ba7b
+ resolution: "@data-client/core@npm:0.15.0-beta-20251006024044-92bd01c4976f2921993b8c9f1e4dbb87af87ba7b"
+ dependencies:
+ "@babel/runtime": "npm:^7.20.0"
+ "@data-client/normalizr": "npm:0.15.0-beta-20251006024044-92bd01c4976f2921993b8c9f1e4dbb87af87ba7b"
+ flux-standard-action: "npm:^2.1.1"
+ checksum: 10c0/b0a9083b61e3404e199757233404eca51c5149d167490a9159af0ce81b15a58dd045d3777cbf0f0df43c2764d8897d5c71133b8be1ae434f81588b83863eeb65
+ languageName: node
+ linkType: hard
+
"@data-client/endpoint@npm:^0.14.25, @data-client/endpoint@workspace:*, @data-client/endpoint@workspace:^, @data-client/endpoint@workspace:packages/endpoint":
version: 0.0.0-use.local
resolution: "@data-client/endpoint@workspace:packages/endpoint"
@@ -3256,6 +3267,15 @@ __metadata:
languageName: unknown
linkType: soft
+"@data-client/normalizr@npm:0.15.0-beta-20251006024044-92bd01c4976f2921993b8c9f1e4dbb87af87ba7b":
+ version: 0.15.0-beta-20251006024044-92bd01c4976f2921993b8c9f1e4dbb87af87ba7b
+ resolution: "@data-client/normalizr@npm:0.15.0-beta-20251006024044-92bd01c4976f2921993b8c9f1e4dbb87af87ba7b"
+ dependencies:
+ "@babel/runtime": "npm:^7.20.0"
+ checksum: 10c0/e9cbd31b3376e9ed5d08b241af6a09351bc6878156ab0c52c23648b1716b7578aff2d6affef1df2ad1552d9c7912dd72053325c7b0db648ca63eec26a1fcb4bc
+ languageName: node
+ linkType: hard
+
"@data-client/normalizr@npm:^0.14.22, @data-client/normalizr@workspace:*, @data-client/normalizr@workspace:^, @data-client/normalizr@workspace:packages/normalizr":
version: 0.0.0-use.local
resolution: "@data-client/normalizr@workspace:packages/normalizr"
@@ -3401,6 +3421,34 @@ __metadata:
languageName: unknown
linkType: soft
+"@data-client/vue@workspace:packages/vue":
+ version: 0.0.0-use.local
+ resolution: "@data-client/vue@workspace:packages/vue"
+ dependencies:
+ "@anansi/browserslist-config": "npm:^1.4.2"
+ "@babel/runtime": "npm:^7.20.0"
+ "@data-client/core": "npm:^0.15.0-beta-0"
+ "@data-client/rest": "workspace:*"
+ "@data-client/test": "workspace:*"
+ "@jest/globals": "npm:^30.0.0"
+ "@js-temporal/polyfill": "npm:^0.5.0"
+ "@types/jest": "npm:30.0.0"
+ "@types/node": "npm:^22.0.0"
+ "@vue/test-utils": "npm:^2.4.0"
+ jest-environment-jsdom: "npm:^30.0.0"
+ jest-mock: "npm:^30.0.0"
+ nock: "npm:13.3.1"
+ rollup-plugins: "workspace:*"
+ vue: "npm:^3.4.0"
+ peerDependencies:
+ "@types/vue": ^3.0.0
+ vue: ^3.0.0
+ peerDependenciesMeta:
+ "@types/vue":
+ optional: true
+ languageName: unknown
+ linkType: soft
+
"@discoveryjs/json-ext@npm:0.5.7":
version: 0.5.7
resolution: "@discoveryjs/json-ext@npm:0.5.7"
@@ -5041,6 +5089,13 @@ __metadata:
languageName: node
linkType: hard
+"@jridgewell/sourcemap-codec@npm:^1.5.5":
+ version: 1.5.5
+ resolution: "@jridgewell/sourcemap-codec@npm:1.5.5"
+ checksum: 10c0/f9e538f302b63c0ebc06eecb1dd9918dd4289ed36147a0ddce35d6ea4d7ebbda243cda7b2213b6a5e1d8087a298d5cf630fb2bd39329cdecb82017023f6081a0
+ languageName: node
+ linkType: hard
+
"@jridgewell/trace-mapping@npm:0.3.9":
version: 0.3.9
resolution: "@jridgewell/trace-mapping@npm:0.3.9"
@@ -5856,6 +5911,13 @@ __metadata:
languageName: node
linkType: hard
+"@one-ini/wasm@npm:0.1.1":
+ version: 0.1.1
+ resolution: "@one-ini/wasm@npm:0.1.1"
+ checksum: 10c0/54700e055037f1a63bfcc86d24822203b25759598c2c3e295d1435130a449108aebc119c9c2e467744767dbe0b6ab47a182c61aa1071ba7368f5e20ab197ba65
+ languageName: node
+ linkType: hard
+
"@opentelemetry/api@npm:1.9.0":
version: 1.9.0
resolution: "@opentelemetry/api@npm:1.9.0"
@@ -8493,6 +8555,56 @@ __metadata:
languageName: node
linkType: hard
+"@vue/compiler-core@npm:3.5.21":
+ version: 3.5.21
+ resolution: "@vue/compiler-core@npm:3.5.21"
+ dependencies:
+ "@babel/parser": "npm:^7.28.3"
+ "@vue/shared": "npm:3.5.21"
+ entities: "npm:^4.5.0"
+ estree-walker: "npm:^2.0.2"
+ source-map-js: "npm:^1.2.1"
+ checksum: 10c0/b8fa1003551815a27381fb242cf4e52cbb22571009506be91264e288a6b69c24a9d31f8aa76087fffce44d56a71f742953c765d32e55c5b4defd97be904b45b1
+ languageName: node
+ linkType: hard
+
+"@vue/compiler-dom@npm:3.5.21":
+ version: 3.5.21
+ resolution: "@vue/compiler-dom@npm:3.5.21"
+ dependencies:
+ "@vue/compiler-core": "npm:3.5.21"
+ "@vue/shared": "npm:3.5.21"
+ checksum: 10c0/84c5eb1a99f2c73dfc5596bce3ce3672b30712393b4399e5906d391939e85c0e0c756e344e8d8fdd4b853186fd9ae64786927ecf8b76e12ad47b783c92bcbe55
+ languageName: node
+ linkType: hard
+
+"@vue/compiler-sfc@npm:3.5.21":
+ version: 3.5.21
+ resolution: "@vue/compiler-sfc@npm:3.5.21"
+ dependencies:
+ "@babel/parser": "npm:^7.28.3"
+ "@vue/compiler-core": "npm:3.5.21"
+ "@vue/compiler-dom": "npm:3.5.21"
+ "@vue/compiler-ssr": "npm:3.5.21"
+ "@vue/shared": "npm:3.5.21"
+ estree-walker: "npm:^2.0.2"
+ magic-string: "npm:^0.30.18"
+ postcss: "npm:^8.5.6"
+ source-map-js: "npm:^1.2.1"
+ checksum: 10c0/5aea296dbfd3d734a457b3026e08a70ead16e0a0814b2c96732a0e12c773574b1582b36b2eaedf8364953ed002aec6877d5c60b60bbc0c4ea3c76e5f637bb2bc
+ languageName: node
+ linkType: hard
+
+"@vue/compiler-ssr@npm:3.5.21":
+ version: 3.5.21
+ resolution: "@vue/compiler-ssr@npm:3.5.21"
+ dependencies:
+ "@vue/compiler-dom": "npm:3.5.21"
+ "@vue/shared": "npm:3.5.21"
+ checksum: 10c0/5baba67df45372f455dd83ada011e2090703a31b27787987a42174ced6010091b4f7fb7bdff22cc4787b4b195ec431fae483bbac7a07372a7cda6f4d775cd718
+ languageName: node
+ linkType: hard
+
"@vue/preload-webpack-plugin@npm:^2.0.0":
version: 2.0.0
resolution: "@vue/preload-webpack-plugin@npm:2.0.0"
@@ -8503,6 +8615,66 @@ __metadata:
languageName: node
linkType: hard
+"@vue/reactivity@npm:3.5.21":
+ version: 3.5.21
+ resolution: "@vue/reactivity@npm:3.5.21"
+ dependencies:
+ "@vue/shared": "npm:3.5.21"
+ checksum: 10c0/d2396705d37544d6d504873e62d09a46f3c5989c6d80b2eedc85848906477e050bf6bcb154ce072a48a270f44ac910670207a8ae94df63de4f8588181bb32557
+ languageName: node
+ linkType: hard
+
+"@vue/runtime-core@npm:3.5.21":
+ version: 3.5.21
+ resolution: "@vue/runtime-core@npm:3.5.21"
+ dependencies:
+ "@vue/reactivity": "npm:3.5.21"
+ "@vue/shared": "npm:3.5.21"
+ checksum: 10c0/40878341befc8bb3390ae33165a5c9e52e81dd555ba8b889de95f5ddc519f16f97636bc51d5cf1e67a064329068b0c399ea5c9784dc75a5260bc6a519495e3bd
+ languageName: node
+ linkType: hard
+
+"@vue/runtime-dom@npm:3.5.21":
+ version: 3.5.21
+ resolution: "@vue/runtime-dom@npm:3.5.21"
+ dependencies:
+ "@vue/reactivity": "npm:3.5.21"
+ "@vue/runtime-core": "npm:3.5.21"
+ "@vue/shared": "npm:3.5.21"
+ csstype: "npm:^3.1.3"
+ checksum: 10c0/047a468fbd2ce4ad6b6cc6fa47da8671f9f648e8a24164b423eab42c2a45547b73f14c33a7439c1a7d348e5ea7fe3020176a7138b69ced3cb224b399c6898267
+ languageName: node
+ linkType: hard
+
+"@vue/server-renderer@npm:3.5.21":
+ version: 3.5.21
+ resolution: "@vue/server-renderer@npm:3.5.21"
+ dependencies:
+ "@vue/compiler-ssr": "npm:3.5.21"
+ "@vue/shared": "npm:3.5.21"
+ peerDependencies:
+ vue: 3.5.21
+ checksum: 10c0/4899387eb9885b17315ddfafd1e28d362a3dba0f781812fc8dc2a2f323789b8b193b8e9a0b7f9610a6fbbf4a2e83620b26c0f9e229598413fb220ba02e56a7df
+ languageName: node
+ linkType: hard
+
+"@vue/shared@npm:3.5.21":
+ version: 3.5.21
+ resolution: "@vue/shared@npm:3.5.21"
+ checksum: 10c0/fbaf2e973d232ccd6d9afd3440510e2436c5e918f6634eb3e0f95d148041f7b9347bcb349db6265f2ee92e5ffd0e6751bdc649698c52f9179b45d93f68473706
+ languageName: node
+ linkType: hard
+
+"@vue/test-utils@npm:^2.4.0":
+ version: 2.4.6
+ resolution: "@vue/test-utils@npm:2.4.6"
+ dependencies:
+ js-beautify: "npm:^1.14.9"
+ vue-component-type-helpers: "npm:^2.0.0"
+ checksum: 10c0/37fa46cb6b98f90affb2faf5aa41422617bbd23ff35bc714d08035334e593ae31d18757d5ae688f778dd8b4c28de431601c0b9b7ca17fc1b55f1401a5577375e
+ languageName: node
+ linkType: hard
+
"@webassemblyjs/ast@npm:1.14.1, @webassemblyjs/ast@npm:^1.14.1":
version: 1.14.1
resolution: "@webassemblyjs/ast@npm:1.14.1"
@@ -10423,7 +10595,21 @@ __metadata:
languageName: node
linkType: hard
-"browserslist@npm:^4.0.0, browserslist@npm:^4.18.1, browserslist@npm:^4.23.0, browserslist@npm:^4.23.3, browserslist@npm:^4.24.0, browserslist@npm:^4.24.4, browserslist@npm:^4.24.5, browserslist@npm:^4.25.0, browserslist@npm:^4.25.1":
+"browserslist@npm:^4.0.0, browserslist@npm:^4.18.1, browserslist@npm:^4.23.0, browserslist@npm:^4.23.3, browserslist@npm:^4.24.0, browserslist@npm:^4.24.4, browserslist@npm:^4.25.0, browserslist@npm:^4.25.1":
+ version: 4.25.4
+ resolution: "browserslist@npm:4.25.4"
+ dependencies:
+ caniuse-lite: "npm:^1.0.30001737"
+ electron-to-chromium: "npm:^1.5.211"
+ node-releases: "npm:^2.0.19"
+ update-browserslist-db: "npm:^1.1.3"
+ bin:
+ browserslist: cli.js
+ checksum: 10c0/2b105948990dc2fc0bc2536b4889aadfa15d637e1d857a121611a704cdf539a68f575a391f6bf8b7ff19db36cee1b7834565571f35a7ea691051d2e7fb4f2eb1
+ languageName: node
+ linkType: hard
+
+"browserslist@npm:^4.24.5":
version: 4.26.3
resolution: "browserslist@npm:4.26.3"
dependencies:
@@ -10837,6 +11023,13 @@ __metadata:
languageName: node
linkType: hard
+"caniuse-lite@npm:^1.0.30001737":
+ version: 1.0.30001741
+ resolution: "caniuse-lite@npm:1.0.30001741"
+ checksum: 10c0/45746f896205a61a8eeb85a32aeca243ebce640cd6eb80d04949d9389a13f4659c737860300d7b988057599f0958c55eeab74ec02ce9ef137feb7d006e75fec1
+ languageName: node
+ linkType: hard
+
"capture-stack-trace@npm:^1.0.0":
version: 1.0.2
resolution: "capture-stack-trace@npm:1.0.2"
@@ -11699,7 +11892,7 @@ __metadata:
languageName: node
linkType: hard
-"config-chain@npm:^1.1.11":
+"config-chain@npm:^1.1.11, config-chain@npm:^1.1.13":
version: 1.1.13
resolution: "config-chain@npm:1.1.13"
dependencies:
@@ -13740,6 +13933,20 @@ __metadata:
languageName: node
linkType: hard
+"editorconfig@npm:^1.0.4":
+ version: 1.0.4
+ resolution: "editorconfig@npm:1.0.4"
+ dependencies:
+ "@one-ini/wasm": "npm:0.1.1"
+ commander: "npm:^10.0.0"
+ minimatch: "npm:9.0.1"
+ semver: "npm:^7.5.3"
+ bin:
+ editorconfig: bin/editorconfig
+ checksum: 10c0/ed6985959d7b34a56e1c09bef118758c81c969489b768d152c93689fce8403b0452462e934f665febaba3478eebc0fd41c0a36100783eaadf6d926c4abc87a3d
+ languageName: node
+ linkType: hard
+
"ee-first@npm:1.1.1":
version: 1.1.1
resolution: "ee-first@npm:1.1.1"
@@ -13758,6 +13965,13 @@ __metadata:
languageName: node
linkType: hard
+"electron-to-chromium@npm:^1.5.211":
+ version: 1.5.214
+ resolution: "electron-to-chromium@npm:1.5.214"
+ checksum: 10c0/76ca22fd97a2dad84a710915b5984263b31e61c7883cd3ec0c11c0d7beb3fa628780cdfd05a96ec79a904ea1c910cf02c513db60f31b627c96743e50f6b11a2e
+ languageName: node
+ linkType: hard
+
"electron-to-chromium@npm:^1.5.227":
version: 1.5.228
resolution: "electron-to-chromium@npm:1.5.228"
@@ -15883,7 +16097,7 @@ __metadata:
languageName: node
linkType: hard
-"glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.7":
+"glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.7, glob@npm:^10.4.2":
version: 10.4.5
resolution: "glob@npm:10.4.5"
dependencies:
@@ -19169,6 +19383,30 @@ __metadata:
languageName: node
linkType: hard
+"js-beautify@npm:^1.14.9":
+ version: 1.15.4
+ resolution: "js-beautify@npm:1.15.4"
+ dependencies:
+ config-chain: "npm:^1.1.13"
+ editorconfig: "npm:^1.0.4"
+ glob: "npm:^10.4.2"
+ js-cookie: "npm:^3.0.5"
+ nopt: "npm:^7.2.1"
+ bin:
+ css-beautify: js/bin/css-beautify.js
+ html-beautify: js/bin/html-beautify.js
+ js-beautify: js/bin/js-beautify.js
+ checksum: 10c0/300386f648579feacda98640742e8db50d4504bc896673af8bc784a5864585abf89ad8d1f257f2cfd4e3da951e0e4d1f027aa3c21537edb920bd498a0e27bd86
+ languageName: node
+ linkType: hard
+
+"js-cookie@npm:^3.0.5":
+ version: 3.0.5
+ resolution: "js-cookie@npm:3.0.5"
+ checksum: 10c0/04a0e560407b4489daac3a63e231d35f4e86f78bff9d792011391b49c59f721b513411cd75714c418049c8dc9750b20fcddad1ca5a2ca616c3aca4874cce5b3a
+ languageName: node
+ linkType: hard
+
"js-tokens@npm:^3.0.0 || ^4.0.0, js-tokens@npm:^4.0.0":
version: 4.0.0
resolution: "js-tokens@npm:4.0.0"
@@ -20042,6 +20280,15 @@ __metadata:
languageName: node
linkType: hard
+"magic-string@npm:^0.30.18":
+ version: 0.30.19
+ resolution: "magic-string@npm:0.30.19"
+ dependencies:
+ "@jridgewell/sourcemap-codec": "npm:^1.5.5"
+ checksum: 10c0/db23fd2e2ee98a1aeb88a4cdb2353137fcf05819b883c856dd79e4c7dfb25151e2a5a4d5dbd88add5e30ed8ae5c51bcf4accbc6becb75249d924ec7b4fbcae27
+ languageName: node
+ linkType: hard
+
"make-dir@npm:^2.0.0, make-dir@npm:^2.1.0":
version: 2.1.0
resolution: "make-dir@npm:2.1.0"
@@ -21614,6 +21861,15 @@ __metadata:
languageName: node
linkType: hard
+"minimatch@npm:9.0.1":
+ version: 9.0.1
+ resolution: "minimatch@npm:9.0.1"
+ dependencies:
+ brace-expansion: "npm:^2.0.1"
+ checksum: 10c0/aa043eb8822210b39888a5d0d28df0017b365af5add9bd522f180d2a6962de1cbbf1bdeacdb1b17f410dc3336bc8d76fb1d3e814cdc65d00c2f68e01f0010096
+ languageName: node
+ linkType: hard
+
"minimatch@npm:^10.0.3":
version: 10.0.3
resolution: "minimatch@npm:10.0.3"
@@ -22227,6 +22483,13 @@ __metadata:
languageName: node
linkType: hard
+"node-releases@npm:^2.0.19":
+ version: 2.0.19
+ resolution: "node-releases@npm:2.0.19"
+ checksum: 10c0/52a0dbd25ccf545892670d1551690fe0facb6a471e15f2cfa1b20142a5b255b3aa254af5f59d6ecb69c2bec7390bc643c43aa63b13bf5e64b6075952e716b1aa
+ languageName: node
+ linkType: hard
+
"node-releases@npm:^2.0.21":
version: 2.0.21
resolution: "node-releases@npm:2.0.21"
@@ -22277,6 +22540,17 @@ __metadata:
languageName: node
linkType: hard
+"nopt@npm:^7.2.1":
+ version: 7.2.1
+ resolution: "nopt@npm:7.2.1"
+ dependencies:
+ abbrev: "npm:^2.0.0"
+ bin:
+ nopt: bin/nopt.js
+ checksum: 10c0/a069c7c736767121242037a22a788863accfa932ab285a1eb569eb8cd534b09d17206f68c37f096ae785647435e0c5a5a0a67b42ec743e481a455e5ae6a6df81
+ languageName: node
+ linkType: hard
+
"nopt@npm:^8.0.0":
version: 8.1.0
resolution: "nopt@npm:8.1.0"
@@ -31058,6 +31332,31 @@ __metadata:
languageName: node
linkType: hard
+"vue-component-type-helpers@npm:^2.0.0":
+ version: 2.2.12
+ resolution: "vue-component-type-helpers@npm:2.2.12"
+ checksum: 10c0/ce15a2cdec4a57262a292ac1565aa18da9be73f3dfbcf28758a8a541199944bfff1aefb75802d6de5d955a5529c9667f1b651c659d14815c075e8965ac05175d
+ languageName: node
+ linkType: hard
+
+"vue@npm:^3.4.0":
+ version: 3.5.21
+ resolution: "vue@npm:3.5.21"
+ dependencies:
+ "@vue/compiler-dom": "npm:3.5.21"
+ "@vue/compiler-sfc": "npm:3.5.21"
+ "@vue/runtime-dom": "npm:3.5.21"
+ "@vue/server-renderer": "npm:3.5.21"
+ "@vue/shared": "npm:3.5.21"
+ peerDependencies:
+ typescript: "*"
+ peerDependenciesMeta:
+ typescript:
+ optional: true
+ checksum: 10c0/4a635b211e43d00a75f35fbd7413b3a5067f97638be5e11d1b3e2860d7b85444bd0288593c63e068366b9b2371cb5cf05a451ff6bc82246cd7092b17c6711100
+ languageName: node
+ linkType: hard
+
"w3c-xmlserializer@npm:^5.0.0":
version: 5.0.0
resolution: "w3c-xmlserializer@npm:5.0.0"