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 @@ +

+
+ + Reactive Data Client + +
+

+ +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. + +
+ +[![CircleCI](https://circleci.com/gh/reactive/data-client/tree/master.svg?style=shield)](https://circleci.com/gh/reactive/data-client) +[![Coverage Status](https://img.shields.io/codecov/c/gh/reactive/data-client/master.svg?style=flat-square)](https://app.codecov.io/gh/reactive/data-client?branch=master) +[![Percentage of issues still open](https://isitmaintained.com/badge/open/reactive/data-client.svg)](https://github.com/reactive/data-client/issues 'Percentage of issues still open') +[![bundle size](https://img.shields.io/bundlephobia/minzip/@data-client/vue?style=flat-square)](https://bundlephobia.com/result?p=@data-client/vue) +[![npm version](https://img.shields.io/npm/v/@data-client/vue.svg?style=flat-square)](https://www.npmjs.com/package/@data-client/vue) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com) +[![Codegen GPT](https://img.shields.io/badge/chatGPT-74aa9c?style=flat-square&logo=openai&logoColor=white)](https://chatgpt.com/g/g-682609591fe48191a6850901521b4e4b-typescript-rest-codegen) +[![Chat](https://img.shields.io/discord/768254430381735967.svg?style=flat-square&colorB=758ED3)](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 + + + +``` + +### [Reactive Mutations](https://dataclient.io/docs/getting-started/mutations) + +```vue + + + +``` + +### [Subscriptions](https://dataclient.io/docs/api/useLive) + +```vue + + + +``` + +### [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] ![TS](./typescript.svg?sanitize=true) 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"