diff --git a/docs/api/vue-composable.api.md b/docs/api/vue-composable.api.md index 34d35a143..c2f4fc822 100644 --- a/docs/api/vue-composable.api.md +++ b/docs/api/vue-composable.api.md @@ -3,12 +3,18 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { App } from "@vue/runtime-core"; import { ComputedRef } from "@vue/runtime-core"; +import { CustomInspectorNode } from "@vue/devtools-api"; +import { CustomInspectorOptions } from "@vue/devtools-api"; +import { CustomInspectorState } from "@vue/devtools-api"; import { DeepReadonly } from "@vue/runtime-core"; +import { DevtoolsPluginApi } from "@vue/devtools-api"; import { InjectionKey } from "@vue/runtime-core"; import { Plugin as Plugin_2 } from "@vue/runtime-core"; import { provide } from "@vue/runtime-core"; import { Ref } from "@vue/runtime-core"; +import { TimelineEvent } from "@vue/devtools-api"; import { UnwrapRef } from "@vue/runtime-core"; // @public (undocumented) @@ -350,6 +356,68 @@ export function deepClone( ...sources: T[] ): T; +// @public (undocumented) +export interface DevtoolInspectorNode extends CustomInspectorNode { + // (undocumented) + children: DevtoolInspectorNode[]; + // (undocumented) + state: CustomInspectorState; +} + +// @public (undocumented) +export interface DevtoolInspectorNodeState { + // (undocumented) + "register module": RefTyped[]; + // (undocumented) + "unregister module": RefTyped[]; + // (undocumented) + "vuex bindings": RefTyped[]; + // (undocumented) + $attrs: RefTyped[]; + // (undocumented) + $refs: RefTyped[]; + // (undocumented) + [key: string]: RefTyped[]; + // (undocumented) + computed: RefTyped[]; + // (undocumented) + getters: RefTyped[]; + // (undocumented) + mutation: RefTyped[]; + // (undocumented) + props: RefTyped[]; + // (undocumented) + setup: RefTyped[]; + // (undocumented) + state: RefTyped[]; + // (undocumented) + undefined: RefTyped[]; +} + +// @public (undocumented) +export interface DevtoolInspectorNodeStateValue { + // (undocumented) + editable: boolean; + // (undocumented) + objectType: string; + // (undocumented) + type: string; + // (undocumented) + value: any; +} + +// @public (undocumented) +export type DevtoolsInpectorNodeFilter = ( + search: string, + nodes: DevtoolInspectorNode[] +) => DevtoolInspectorNode[]; + +// @public (undocumented) +export type DevtoolsInpectorStateFilter = ( + search: string, + state: CustomInspectorState +) => CustomInspectorState; + // Warning: (ae-forgotten-export) The symbol "RetryDelayFactory" needs to be exported by the entry point index.d.ts // // @public (undocumented) @@ -384,6 +452,9 @@ export function getCssVariableFor( name: string ): CssVariable; +// @public +export function getDevtools(): DevtoolsPluginApi | undefined; + // @public (undocumented) export const hydrationPlugin: Plugin_2; @@ -891,7 +962,7 @@ export const enum RefSharedMessageType { // (undocumented) SYNC = 1, // (undocumented) - UPDATE = 2 + UPDATE = 2, } // @public (undocumented) @@ -969,6 +1040,9 @@ export function setCssVariableFor( value: CssVariable ): void; +// @public +export function setDevtools(app: App, api: DevtoolsPluginApi): void; + // @public export function setI18n< T extends i18nDefinition, @@ -985,7 +1059,7 @@ export const enum SharedRefMind { // (undocumented) HIVE = 0, // (undocumented) - MASTER = 1 + MASTER = 1, } // @public (undocumented) @@ -1054,6 +1128,7 @@ export interface UndoOperation { export interface UndoOptions { clone: (entry: T) => T; deep: boolean; + devtoolId?: string; maxLength: number; } @@ -1277,6 +1352,31 @@ export function useDebounce( options?: Options ): T; +// @public (undocumented) +export const UseDevtoolsApp: (app: App, id?: string, label?: string) => void; + +// @public (undocumented) +export function useDevtoolsInpector( + options: CustomInspectorOptions & { + nodeFilter?: DevtoolsInpectorNodeFilter; + stateFilter?: DevtoolsInpectorStateFilter; + }, + nodeList?: DevtoolInspectorNode[] +): { + nodes: Ref; +}; + +// @public (undocumented) +export function useDevtoolsTimelineLayer( + id: string, + label: string, + color: number +): { + id: string; + addEvent: (event: TimelineEvent, all?: boolean | undefined) => any; + pushEvent: (event: Omit) => any; +}; + // @public (undocumented) export function useEvent( el: RefTyped, @@ -1352,6 +1452,7 @@ export function useFetch( // @public (undocumented) export interface UseFetchOptions { + devtoolId?: boolean | string; isJson?: boolean; parseImmediate?: boolean; unmountCancel?: boolean; @@ -2069,6 +2170,17 @@ export const VERSION: string; // @public (undocumented) export const VUE_VERSION: "2" | "3"; +// @public (undocumented) +export const VueComposableDevtools: { + install( + app: App, + options?: { + id: string; + label: string; + } + ): void; +}; + // @public (undocumented) export interface WebSocketReturn { // (undocumented) diff --git a/examples/vue-composable-example/package.json b/examples/vue-composable-example/package.json index 476780282..655e4072a 100644 --- a/examples/vue-composable-example/package.json +++ b/examples/vue-composable-example/package.json @@ -8,9 +8,9 @@ "lint": "vue-cli-service lint" }, "dependencies": { - "@vue-composable/axios": "^1.0.0-beta.1", - "vue": "^2.6.11", - "vue-composable": "^1.0.0-beta.1" + "@vue-composable/axios": "^1.0.0-beta.5", + "vue": "^2.6.12", + "vue-composable": "^1.0.0-beta.5" }, "devDependencies": { "@vue/cli-plugin-babel": "4.4.6", diff --git a/examples/vue-composable-example/yarn.lock b/examples/vue-composable-example/yarn.lock index abf4dbf27..8aeae3a32 100644 --- a/examples/vue-composable-example/yarn.lock +++ b/examples/vue-composable-example/yarn.lock @@ -1151,12 +1151,12 @@ resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.2.tgz#690a1475b84f2a884fd07cd797c00f5f31356ea8" integrity sha512-ce5d3q03Ex0sy4R14722Rmt6MT07Ua+k4FwDfdcToYJcMKNtRVQvJ6JCAPdAmAnbRb6CsX6aYb9m96NGod9uTw== -"@vue-composable/axios@^1.0.0-beta.1": - version "1.0.0-beta.3" - resolved "https://registry.yarnpkg.com/@vue-composable/axios/-/axios-1.0.0-beta.3.tgz#5e50a1cf15a1efbb02339a659835eefb40546ccf" - integrity sha512-sPkivRnWvYd4TsDia3ivpqxiw4UKCac9t3SuarJHBGJbw1l2LG2G6XT9LNdkHCIDe4yxqh09OGXqyO3P6jNMYg== +"@vue-composable/axios@^1.0.0-beta.5": + version "1.0.0-beta.5" + resolved "https://registry.yarnpkg.com/@vue-composable/axios/-/axios-1.0.0-beta.5.tgz#aea2c1f3a934ffe689a3c32a0e939b118b79b32c" + integrity sha512-RIH325JtgcVLifXB+eT0tQyPl6uTyVipoXcor+lULAi5XIkHZa9OluRs8sRXCWrx2ByQIb+FUhnxuTBnRKJiqg== dependencies: - vue-composable "^1.0.0-beta.3" + vue-composable "^1.0.0-beta.5" "@vue/babel-helper-vue-jsx-merge-props@^1.0.0": version "1.0.0" @@ -7887,10 +7887,10 @@ vm-browserify@^1.0.1: resolved "https://registry.yarnpkg.com/vm-browserify/-/vm-browserify-1.1.2.tgz#78641c488b8e6ca91a75f511e7a3b32a86e5dda0" integrity sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ== -vue-composable@^1.0.0-beta.1, vue-composable@^1.0.0-beta.3: - version "1.0.0-beta.3" - resolved "https://registry.yarnpkg.com/vue-composable/-/vue-composable-1.0.0-beta.3.tgz#ba442d24768b8a452dc4b313f42840ef8ecd3d99" - integrity sha512-PZ19JR3/aZc/PPDZhJC9X4oytSo7eDpoKku6cHQX4OHAGRxg7Cqb/rDt7mbZk3zCIb3GD6jpR3Qm8HqliV745Q== +vue-composable@^1.0.0-beta.5: + version "1.0.0-beta.5" + resolved "https://registry.yarnpkg.com/vue-composable/-/vue-composable-1.0.0-beta.5.tgz#3de1ee9dbd1768c575d06a3657c1b7234df9e49c" + integrity sha512-AsuyLdUhbsdYlnDC7Se3207j8WXNoy0/9hwGmkuxJE5QIylOTd6vkg4nyJ8j+vTL740nEofVwnwsH20W0pc2jw== vue-hot-reload-api@^2.3.0: version "2.3.4" @@ -7929,10 +7929,10 @@ vue-template-es2015-compiler@^1.9.0: resolved "https://registry.yarnpkg.com/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.9.1.tgz#1ee3bc9a16ecbf5118be334bb15f9c46f82f5825" integrity sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw== -vue@^2.6.11: - version "2.6.11" - resolved "https://registry.yarnpkg.com/vue/-/vue-2.6.11.tgz#76594d877d4b12234406e84e35275c6d514125c5" - integrity sha512-VfPwgcGABbGAue9+sfrD4PuwFar7gPb1yl1UK1MwXoQPAw0BKSqWfoYCT/ThFrdEVWoI51dBuyCoiNU9bZDZxQ== +vue@^2.6.12: + version "2.6.12" + resolved "https://registry.yarnpkg.com/vue/-/vue-2.6.12.tgz#f5ebd4fa6bd2869403e29a896aed4904456c9123" + integrity sha512-uhmLFETqPPNyuLLbsKz6ioJ4q7AZHzD8ZVFNATNyICSZouqP2Sz0rotWQC8UNBF6VGSCs5abnKJoStA6JbCbfg== watchpack@^1.6.0: version "1.6.0" diff --git a/package.json b/package.json index 8512376de..6e2742b63 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,7 @@ "lodash.camelcase": "^4.3.0", "minimist": "^1.2.5", "mock-socket": "^9.0.3", + "prettier": "^2.1.1", "rimraf": "^3.0.2", "rollup": "^2.26.10", "rollup-plugin-terser": "^7.0.2", diff --git a/packages/vue-composable/README.md b/packages/vue-composable/README.md index 6833f8964..aa06e603f 100644 --- a/packages/vue-composable/README.md +++ b/packages/vue-composable/README.md @@ -151,6 +151,103 @@ Check our [documentation](https://pikax.me/vue-composable/) This is a monorepo project, please check [packages](packages/) +## Devtools + +There's some experimental devtools support starting from `1.0.0-beta.6`, only available for `vue-next` and `devtools beta 6`. + +- [devtools beta chrome](https://chrome.google.com/webstore/detail/vuejs-devtools/ljjemllljcmogpfapbkkighbhhppjdbg) + +### Install plugin + +To use devtools you need to install the plugin first: + +```ts +import { createApp } from "vue"; +import { VueComposableDevtools } from "vue-composable"; +import App from "./App.vue"; + +const app = createApp(App); +app.use(VueComposableDevtools); +// or +app.use(VueComposableDevtools, { + id: "vue-composable", + label: "devtool composables", +}); + +app.mount("#app"); +``` + +### Timeline events + +To add timeline events: + +```ts +const id = "vue-composable"; +const label = "Test events"; +const color = 0x92a2bf; + +const { addEvent, pushEvent } = useDevtoolsTimelineLayer( + id, + description, + color +); + +// adds event to a specific point in the timeline +addEvent({ + time: Date.now(), + data: { + // data object + }, + meta: { + // meta object + }, +}); + +// adds event with `time: Date.now()` +pushEvent({ + data: { + // data object + }, + meta: { + // meta object + }, +}); +``` + +### Inspector + +Allows to create a new inpector for your data. + +> I'm still experimenting on how to expose this API on a composable, this will likely to change in the future, suggestions are welcome. + +```ts +useDevtoolsInpector( + { + id: "vue-composable", + label: "test vue-composable", + }, + // list of nodes, this can be reactive + [ + { + id: "test", + label: "test - vue-composable", + depth: 0, + state: { + composable: [ + { + editable: false, + key: "count", + objectType: "Ref", + type: "setup", + value: myRefValue, + }, + ], + }, + }, + ] +); +``` + ## Contributing You can contribute raising issues and by helping out with code. diff --git a/packages/vue-composable/__tests__/web/fetch.spec.ts b/packages/vue-composable/__tests__/web/fetch.spec.ts index aa47173f4..78f6b57a7 100644 --- a/packages/vue-composable/__tests__/web/fetch.spec.ts +++ b/packages/vue-composable/__tests__/web/fetch.spec.ts @@ -10,9 +10,9 @@ describe("fetch", () => { Promise.resolve({ json() { return Promise.resolve({ - test: 1 + test: 1, }); - } + }, }) ); }); @@ -36,8 +36,8 @@ describe("fetch", () => { it("should call fetch with options", () => { const init = { headers: { - test: "test" - } + test: "test", + }, }; const { exec } = useFetch(); @@ -52,11 +52,11 @@ describe("fetch", () => { it("should exec only be resolved after parsing json()", async () => { const { exec, result } = useFetch({ isJson: true, - parseImmediate: true + parseImmediate: true, }); const wait = 100; const expectedResult = { - test: 2 + test: 2, }; let r; fetchSpy.mockImplementationOnce(async () => { @@ -73,7 +73,7 @@ describe("fetch", () => { }, json() { return (r = expectedResult); - } + }, }; }); @@ -93,11 +93,11 @@ describe("fetch", () => { it("should exec should not parse json() immediate", async () => { const { exec, result } = useFetch({ isJson: true, - parseImmediate: false + parseImmediate: false, }); const wait = 100; const expectedResult = { - test: 2 + test: 2, }; let r; let jsonExecuted = false; @@ -117,7 +117,7 @@ describe("fetch", () => { jsonExecuted = true; await promisedTimeout(wait); return (r = expectedResult); - } + }, }; }); @@ -142,11 +142,11 @@ describe("fetch", () => { it("should exec should not call response.json()", async () => { const { exec, result } = useFetch({ - isJson: false + isJson: false, }); const wait = 100; const expectedResult = { - test: 2 + test: 2, }; let r; let jsonExecuted = false; @@ -165,7 +165,7 @@ describe("fetch", () => { jsonExecuted = true; await promisedTimeout(wait); return (r = expectedResult); - } + }, }; }); @@ -185,7 +185,7 @@ describe("fetch", () => { it("should exec should empty `json` and set the `jsonError` when exception has being thrown parsing json()", async () => { const { exec, json, jsonError } = useFetch({ - isJson: true + isJson: true, }); const exception = new Error("error parsing json"); fetchSpy.mockImplementationOnce(async () => { @@ -201,7 +201,7 @@ describe("fetch", () => { }, async json() { throw exception; - } + }, }; }); @@ -219,7 +219,7 @@ describe("fetch", () => { const expectedResult = { status: 200, - statusText: "OK" + statusText: "OK", }; expect(status.value).toBe(null); @@ -239,7 +239,7 @@ describe("fetch", () => { }, json() { return Promise.resolve(""); - } + }, }; }); @@ -273,23 +273,23 @@ describe("fetch", () => { expect({ cancelledMessage, isCancelled, - error + error, }).toMatchObject({ cancelledMessage: { - value: message + value: message, }, isCancelled: { - value: true - } + value: true, + }, }); }); it("should use first parameter as requestInit", () => { const headers = new Headers({ - test: "1" + test: "1", }); const { exec } = useFetch({ - headers + headers, }); exec("./api/1"); @@ -302,11 +302,11 @@ describe("fetch", () => { it("should use second argument as arguments", () => { const req: Partial = { - url: "./api/1" + url: "./api/1", }; const init = { method: "POST", - isJson: true + isJson: true, }; useFetch(req, init); expect(fetchSpy).toBeCalledWith( @@ -319,17 +319,17 @@ describe("fetch", () => { const url = "./api/1"; const init: RequestInit = { - method: "POST" + method: "POST", }; useFetch(url, init); expect(fetchSpy).toBeCalledWith(url, expect.objectContaining(init)); }); it("should execute request if request is passed", () => { const req: Partial = { - url: "./api/1" + url: "./api/1", }; const init: RequestInit = { - method: "POST" + method: "POST", }; useFetch(req, init); expect(fetchSpy).toBeCalledWith( @@ -352,7 +352,7 @@ describe("fetch", () => { template: `
`, setup() { isCancelled = useFetch("./api").isCancelled; - } + }, }); mount(); @@ -370,7 +370,7 @@ describe("fetch", () => { template: `
`, setup() { isCancelled = useFetch().isCancelled; - } + }, }); mount(); diff --git a/packages/vue-composable/package.json b/packages/vue-composable/package.json index d5f397459..dd1e81b59 100644 --- a/packages/vue-composable/package.json +++ b/packages/vue-composable/package.json @@ -43,6 +43,9 @@ "bin": { "vue-composable-fix": "./scripts/postinstall.js" }, + "dependencies": { + "@vue/devtools-api": "^6.0.0-beta.2" + }, "peerDependencies3": { "@vue/runtime-core": "^3.0.0-rc.2" }, diff --git a/packages/vue-composable/src/api.2.ts b/packages/vue-composable/src/api.2.ts index 9524ca244..f0f8ae3cb 100644 --- a/packages/vue-composable/src/api.2.ts +++ b/packages/vue-composable/src/api.2.ts @@ -19,7 +19,8 @@ export { onBeforeUnmount, onDeactivated, ComputedRef, - UnwrapRef // Plugin, + toRaw, + UnwrapRef, // Plugin, } from "@vue/composition-api"; export { VueConstructor as App } from "vue"; @@ -41,3 +42,9 @@ export function readonly( // FAKE DeepReadonly export type DeepReadonly = Readonly; + +declare module "vue" { + interface VueConstructor { + provide(key: any, value: any): void; + } +} diff --git a/packages/vue-composable/src/api.3.ts b/packages/vue-composable/src/api.3.ts index 3c66021e6..9c7ce9431 100644 --- a/packages/vue-composable/src/api.3.ts +++ b/packages/vue-composable/src/api.3.ts @@ -23,7 +23,8 @@ export { Plugin, App, readonly, - DeepReadonly + toRaw, + DeepReadonly, } from "@vue/runtime-core"; // istanbul ignore next diff --git a/packages/vue-composable/src/api.ts b/packages/vue-composable/src/api.ts index 3c66021e6..9c7ce9431 100644 --- a/packages/vue-composable/src/api.ts +++ b/packages/vue-composable/src/api.ts @@ -23,7 +23,8 @@ export { Plugin, App, readonly, - DeepReadonly + toRaw, + DeepReadonly, } from "@vue/runtime-core"; // istanbul ignore next diff --git a/packages/vue-composable/src/devtools/api.ts b/packages/vue-composable/src/devtools/api.ts new file mode 100644 index 000000000..6891974c8 --- /dev/null +++ b/packages/vue-composable/src/devtools/api.ts @@ -0,0 +1,36 @@ +import { DevtoolsPluginApi } from "@vue/devtools-api"; +import { App, InjectionKey, inject } from "../api"; +import { isFunction } from "../utils"; + +// istanbul ignore next +const DEVTOOLS_KEY: InjectionKey = /*#__PURE__*/ Symbol( + (__DEV__ && "DEVTOOLS_KEY") || `` +); + +/** + * provide devtools api instance to the app + * @param app + * @param api + */ +export function setDevtools(app: App, api: DevtoolsPluginApi) { + if (!isFunction(app.provide)) { + console.warn("[vue-composable] devtools is not supported for vue 2"); + return; + } + app.provide(DEVTOOLS_KEY, api); +} + +/** + * Exposes the internal devtools api instance + */ +export function getDevtools(): DevtoolsPluginApi | undefined { + const empty = {}; + const devtools = inject(DEVTOOLS_KEY, empty) as DevtoolsPluginApi; + if (devtools === empty) { + console.warn( + `[vue-composable] devtools not found, please run app.use(VueComposableDevtools)` + ); + return undefined; + } + return devtools!; +} diff --git a/packages/vue-composable/src/devtools/app.ts b/packages/vue-composable/src/devtools/app.ts new file mode 100644 index 000000000..fc9f39679 --- /dev/null +++ b/packages/vue-composable/src/devtools/app.ts @@ -0,0 +1,23 @@ +import { setupDevtoolsPlugin } from "@vue/devtools-api"; +import { App } from "../api"; +import ProxyApi from "./proxy"; +import { setDevtools } from "./api"; + +export const UseDevtoolsApp = ( + app: App, + id = "vue-composable", + label = "Vue-composable devtools plugin" +) => { + const promise: any = new Promise((res) => { + setupDevtoolsPlugin( + { + id, + label, + app, + }, + res + ); + }); + + setDevtools(app, ProxyApi(promise)); +}; diff --git a/packages/vue-composable/src/devtools/index.ts b/packages/vue-composable/src/devtools/index.ts new file mode 100644 index 000000000..b73d26699 --- /dev/null +++ b/packages/vue-composable/src/devtools/index.ts @@ -0,0 +1,7 @@ +export * from "./api"; +export * from "./app"; +export * from "./install"; + +// composables +export * from "./inspector"; +export * from "./timelineLayer"; diff --git a/packages/vue-composable/src/devtools/inspector.ts b/packages/vue-composable/src/devtools/inspector.ts new file mode 100644 index 000000000..20bfd73b7 --- /dev/null +++ b/packages/vue-composable/src/devtools/inspector.ts @@ -0,0 +1,120 @@ +import { + CustomInspectorOptions, + CustomInspectorNode, + CustomInspectorState +} from "@vue/devtools-api"; +import { reactive, Ref, computed, toRaw, watch, ref } from "../api"; +import { RefTyped } from "../utils"; +import { getDevtools } from "./api"; + +export interface DevtoolInspectorNode extends CustomInspectorNode { + children: DevtoolInspectorNode[]; + + state: CustomInspectorState; +} +export interface DevtoolInspectorNodeStateValue { + editable: boolean; + objectType: string; + type: string; + value: any; +} + +export interface DevtoolInspectorNodeState { + [key: string]: RefTyped[]; + props: RefTyped[]; + undefined: RefTyped[]; + computed: RefTyped[]; + "register module": RefTyped[]; + "unregister module": RefTyped[]; + setup: RefTyped[]; + state: RefTyped[]; + getters: RefTyped[]; + mutation: RefTyped[]; + "vuex bindings": RefTyped[]; + $refs: RefTyped[]; + $attrs: RefTyped[]; +} + +export type DevtoolsInpectorNodeFilter = ( + search: string, + nodes: DevtoolInspectorNode[] +) => DevtoolInspectorNode[]; +export type DevtoolsInpectorStateFilter = ( + search: string, + state: CustomInspectorState +) => CustomInspectorState; + +export function useDevtoolsInpector( + options: CustomInspectorOptions & { + nodeFilter?: DevtoolsInpectorNodeFilter; + stateFilter?: DevtoolsInpectorStateFilter; + }, + nodeList: DevtoolInspectorNode[] = [] +): { nodes: Ref } { + const api = getDevtools(); + const nodes: Ref = ref(nodeList) as any; + + const byId = computed(() => { + if (!nodes.value) return new Map(); + + const r = toRaw(nodes.value); + const m = new Map(); + for (const i of r) { + m.set(i.id, i); + } + return m; + }); + + if (api) { + const id = options.id; + api.addInspector(options); + + // api.on.getInspectorState(); + + api.on.getInspectorTree(payload => { + if (payload.inspectorId != id) return; + console.log(); + if (!nodes.value) return; + + const filter = payload.filter; + let m = toRaw(nodes.value); + if (payload.filter) { + if (options.nodeFilter) { + m = options.nodeFilter(payload.filter, m); + } else { + // TODO better filtering, only currently filtering root nodes + m = m.filter( + x => x.id.indexOf(filter) >= 0 || x.label.indexOf(filter) >= 0 + ); + } + } + payload.rootNodes = m; + }); + + api.on.getInspectorState(payload => { + if (payload.inspectorId != id) return; + + const node = byId.value.get(payload.nodeId); + if (node) { + const s = reactive(node.state); // unwrap + payload.state = options.stateFilter ? options.stateFilter("", s) : s; + } + }); + + watch( + nodes, + () => { + api.sendInspectorTree(id); + api.sendInspectorState(id); + }, + { + immediate: true, + deep: true + } + ); + } + + return { + nodes + }; +} diff --git a/packages/vue-composable/src/devtools/install.ts b/packages/vue-composable/src/devtools/install.ts new file mode 100644 index 000000000..d8956eea1 --- /dev/null +++ b/packages/vue-composable/src/devtools/install.ts @@ -0,0 +1,14 @@ +import { App } from "../api"; +import { UseDevtoolsApp } from "./app"; + +export const VueComposableDevtools = { + install( + app: App, + options: { id: string; label: string } = { + id: "vue-composable", + label: "Vue-composable devtools plugin" + } + ) { + return UseDevtoolsApp(app, options.id, options.label); + } +}; diff --git a/packages/vue-composable/src/devtools/proxy.ts b/packages/vue-composable/src/devtools/proxy.ts new file mode 100644 index 000000000..3079710a5 --- /dev/null +++ b/packages/vue-composable/src/devtools/proxy.ts @@ -0,0 +1,255 @@ +import { DevtoolsPluginApi, Hooks } from "@vue/devtools-api"; +import { promisedTimeout } from "../utils"; + +type OnEvent = { type: Hooks; args: any[] }; +type ApiEvent = { type: keyof DevtoolsPluginApi; args: any[] }; + +let apiProxyFactory: ( + promiseApi: Promise +) => DevtoolsPluginApi = undefined as any; + +async function pushEventsToApi( + api: DevtoolsPluginApi, + EventQueue: OnEvent[], + ApiQueue: ApiEvent[] +) { + setTimeout(async () => { + const priority: (keyof DevtoolsPluginApi)[] = [ + "addTimelineLayer", + "addInspector", + "sendInspectorTree", + "sendInspectorState", + "addTimelineEvent", + ]; + + for (const k of priority) { + for (const it of ApiQueue.filter((x) => x.type === k)) { + // @ts-ignore + api[k](...it.args); + } + await promisedTimeout(20); + } + + new Set( + ApiQueue.filter((x) => x.type === "notifyComponentUpdate").map( + (x) => x.args[0] + ) + ).forEach((x) => api.notifyComponentUpdate(x)); + + // @ts-ignore + EventQueue.forEach((x) => api.on[x.type](...x.args)); + + EventQueue.length = 0; + ApiQueue.length = 0; + }, 100); +} + +if (__VUE_2__) { + apiProxyFactory = (promiseApi) => { + const EventQueue: OnEvent[] = []; + const ApiQueue: ApiEvent[] = []; + let api: DevtoolsPluginApi; + + function queueEvent(type: keyof DevtoolsPluginApi, args: any) { + if (api) { + //@ts-ignore + api[type](...args); + } else { + ApiQueue.push({ type, args }); + } + } + + promiseApi.then((x) => { + api = x; + pushEventsToApi(api, EventQueue, ApiQueue); + }); + + const proxyApi: DevtoolsPluginApi = { + notifyComponentUpdate(_: any): any { + queueEvent("notifyComponentUpdate", arguments); + }, + addTimelineLayer(_) { + queueEvent("addTimelineLayer", arguments); + }, + addTimelineEvent(_): any { + queueEvent("addTimelineEvent", arguments); + }, + + addInspector(_): any { + queueEvent("addInspector", arguments); + }, + sendInspectorTree(_): any { + queueEvent("sendInspectorTree", arguments); + }, + sendInspectorState(_): any { + queueEvent("sendInspectorState", arguments); + }, + + on: { + transformCall(handler): any { + if (api) { + api.on.transformCall(handler); + } else { + //@ts-ignore2 + EventQueue.push({ type: "transformCall", args: arguments }); + } + }, + getAppRecordName(handler): any { + if (api) { + api.on.getAppRecordName(handler); + } else { + //@ts-ignore + EventQueue.push({ type: "getAppRecordName", args: arguments }); + } + }, + getAppRootInstance(handler): any { + if (api) { + api.on.getAppRootInstance(handler); + } else { + //@ts-ignore + EventQueue.push({ type: "getAppRootInstance", args: arguments }); + } + }, + registerApplication(handler): any { + if (api) { + api.on.registerApplication(handler); + } else { + //@ts-ignore + EventQueue.push({ type: "registerApplication", args: arguments }); + } + }, + walkComponentTree(handler): any { + if (api) { + api.on.walkComponentTree(handler); + } else { + //@ts-ignore + EventQueue.push({ type: "walkComponentTree", args: arguments }); + } + }, + walkComponentParents(handler): any { + if (api) { + api.on.walkComponentParents(handler); + } else { + //@ts-ignore + EventQueue.push({ type: "walkComponentParents", args: arguments }); + } + }, + inspectComponent(handler): any { + if (api) { + api.on.inspectComponent(handler); + } else { + //@ts-ignore + EventQueue.push({ type: "inspectComponent", args: arguments }); + } + }, + getComponentBounds(handler): any { + if (api) { + api.on.getComponentBounds(handler); + } else { + //@ts-ignore + EventQueue.push({ type: "getComponentBounds", args: arguments }); + } + }, + getComponentName(handler): any { + if (api) { + api.on.getComponentName(handler); + } else { + //@ts-ignore + EventQueue.push({ type: "getComponentName", args: arguments }); + } + }, + getElementComponent(handler): any { + if (api) { + api.on.getElementComponent(handler); + } else { + //@ts-ignore + EventQueue.push({ type: "getElementComponent", args: arguments }); + } + }, + + getInspectorTree(handler): any { + if (api) { + api.on.getInspectorTree(handler); + } else { + //@ts-ignore + EventQueue.push({ type: "getInspectorTree", args: arguments }); + } + }, + getInspectorState(handler): any { + if (api) { + api.on.getInspectorState(handler); + } else { + //@ts-ignore + EventQueue.push({ type: "getInspectorState", args: arguments }); + } + }, + }, + }; + + return proxyApi; + }; +} else { + apiProxyFactory = (promiseApi) => { + let api: DevtoolsPluginApi; + const EventQueue: OnEvent[] = []; + const ApiQueue: ApiEvent[] = []; + + const onProxy = new Proxy( + {}, + { + get: (target, prop: Hooks) => { + if (api) { + return api.on[prop]; + } else if (prop in target) { + // @ts-ignore + return target[prop]; + } else { + //@ts-ignore + target[prop] = (...args) => { + EventQueue.push({ + type: prop, + args, + }); + }; + } + }, + } + ); + const proxy = new Proxy( + { + on: onProxy, + }, + { + get: (target, prop: keyof DevtoolsPluginApi) => { + if (prop === "on") { + return target.on; + } + if (api) { + return api[prop]; + } + + if (prop in target) { + // @ts-ignore + return target[prop]; + } + + // @ts-ignore + return (target[prop] = (...args) => { + ApiQueue.push({ + type: prop, + args, + }); + }); + }, + } + ); + + promiseApi.then((x) => { + api = x; + pushEventsToApi(api, EventQueue, ApiQueue); + }); + return proxy as any; + }; +} + +export default apiProxyFactory; diff --git a/packages/vue-composable/src/devtools/timelineLayer.ts b/packages/vue-composable/src/devtools/timelineLayer.ts new file mode 100644 index 000000000..03d69adcc --- /dev/null +++ b/packages/vue-composable/src/devtools/timelineLayer.ts @@ -0,0 +1,31 @@ +import { TimelineEvent } from "@vue/devtools-api"; +import { NO_OP } from "../utils"; +import { getDevtools } from "./api"; + +export function useDevtoolsTimelineLayer( + id: string, + label: string, + color: number +) { + const api = getDevtools(); + let addEvent: (event: TimelineEvent, all?: boolean) => any = NO_OP; + let pushEvent: (event: Omit) => any = NO_OP; + if (api) { + api.addTimelineLayer({ + id, + label, + color, + }); + addEvent = (event, all) => + api.addTimelineEvent({ layerId: id, event, all }); + + pushEvent = (event) => addEvent({ ...event, time: Date.now() }); + } + + return { + id, + + addEvent, + pushEvent, + }; +} diff --git a/packages/vue-composable/src/index.ts b/packages/vue-composable/src/index.ts index 125e2d41e..aa973e1de 100644 --- a/packages/vue-composable/src/index.ts +++ b/packages/vue-composable/src/index.ts @@ -15,6 +15,7 @@ export * from "./i18n"; export * from "./meta"; export * from "./ssr"; export * from "./state"; +export * from "./devtools"; export const VERSION = __VERSION__; // istanbul ignore next diff --git a/packages/vue-composable/src/state/undo.ts b/packages/vue-composable/src/state/undo.ts index 87a256562..5497c7a91 100644 --- a/packages/vue-composable/src/state/undo.ts +++ b/packages/vue-composable/src/state/undo.ts @@ -1,5 +1,6 @@ import { ref, computed, watch, Ref, ComputedRef } from "../api"; import { RefTyped, MAX_ARRAY_SIZE, wrap } from "../utils"; +import { useDevtoolsTimelineLayer } from "../devtools"; export interface UndoOptions { /** @@ -19,6 +20,12 @@ export interface UndoOptions { * @default (x)=>x */ clone: (entry: T) => T; + + /** + * Adds it to the devtools timeline + * @default undefined + */ + devtoolId?: string; } export interface UndoOperation { @@ -96,9 +103,45 @@ export function useUndo( const maxLen = (options && options.maxLength) || MAX_ARRAY_SIZE; const clone = (options && options.clone) || ((t: any) => t); + const prev = computed(() => { + // hide current + const p = position.value === 0 ? 1 : position.value; + return timeline.value.slice(p); + }); + const next = computed(() => { + // hide current + const p = position.value === 0 ? 1 : 0; + return timeline.value.slice(p, position.value); + }); + + let addTimelineEvent: + | ((time: number, data: any) => any) + | undefined = undefined; + + if (__DEV__ && options && options.devtoolId) { + const layer = useDevtoolsTimelineLayer( + `useUndo:${options.devtoolId}`, + options.devtoolId, + 0x32a2bf // TODO devtools fix color + ); + addTimelineEvent = (time, data) => + layer.addEvent({ + time, + data: { + value: data, + prev: [...prev.value], + next: [...next.value], + }, + meta: { + prev: [...prev.value], + next: [...next.value], + }, + }); + } + watch( current, - c => { + (c) => { if (timeline.value[position.value] === c) { //ignore because is the same value return; @@ -115,13 +158,17 @@ export function useUndo( if (timeline.value.length > maxLen) { timeline.value.pop(); } + const v = clone(c); + timeline.value.unshift(v); - timeline.value.unshift(clone(c)); + if (addTimelineEvent) { + addTimelineEvent(Date.now(), c); + } }, { ...options, immediate: true, - flush: "sync" + flush: "sync", } ); @@ -136,18 +183,11 @@ export function useUndo( position.value += s; current.value = timeline.value[position.value]; - }; - const prev = computed(() => { - // hide current - const p = position.value === 0 ? 1 : position.value; - return timeline.value.slice(p); - }); - const next = computed(() => { - // hide current - const p = position.value === 0 ? 1 : 0; - return timeline.value.slice(p, position.value); - }); + if (addTimelineEvent) { + addTimelineEvent(Date.now(), clone(current.value)); + } + }; return { value: current, @@ -157,6 +197,6 @@ export function useUndo( jump, prev, - next + next, }; } diff --git a/packages/vue-composable/src/web/fetch.ts b/packages/vue-composable/src/web/fetch.ts index 403764a42..304d2de25 100644 --- a/packages/vue-composable/src/web/fetch.ts +++ b/packages/vue-composable/src/web/fetch.ts @@ -1,6 +1,7 @@ import { isBoolean, isString } from "../utils"; import { ref, computed, Ref, onUnmounted, getCurrentInstance } from "../api"; import { PromiseResultFactory, usePromise } from "../promise"; +import { useDevtoolsTimelineLayer } from "../devtools"; export interface UseFetchOptions { /** @@ -19,6 +20,13 @@ export interface UseFetchOptions { * @default true */ unmountCancel?: boolean; + + /** + * @description devtools timeline, if string sets the id otherwise + * will set the request url + * @default true + */ + devtoolId?: boolean | string; } type ExtractArguments = T extends (...args: infer TArgs) => void @@ -63,6 +71,7 @@ export function useFetch( options?: Partial | (UseFetchOptions & RequestInit), requestInitOptions?: RequestInit & UseFetchOptions ): FetchReturn { + // TODO move to computeAsync const json: Ref = ref(null); const text = ref(""); const blob = ref(); @@ -73,13 +82,13 @@ export function useFetch( ? [ options.isJson !== false, options.parseImmediate !== false, - options.unmountCancel !== false + options.unmountCancel !== false, ] : isFetchOptions(requestInitOptions) ? [ requestInitOptions.isJson !== false, requestInitOptions.parseImmediate !== false, - requestInitOptions.unmountCancel !== false + requestInitOptions.unmountCancel !== false, ] : [true, true, true]; @@ -93,6 +102,35 @@ export function useFetch( : options : undefined; + let addTimelineEvent: + | ((time: number, request: any, extra: any) => any) + | undefined = undefined; + + let devtoolId = __DEV__ + ? isString(options) + ? options + : options && isString((options as Request).url) + ? (options as any).url + : "useFetch" + : undefined; + + if (__DEV__ && devtoolId) { + const layer = useDevtoolsTimelineLayer( + `useFetch:${devtoolId}`, + devtoolId, + 0x32a2bf + ); + addTimelineEvent = (time, request, extra) => + layer.addEvent({ + time, + data: { + ...request, + ...extra, + }, + meta: {}, + }); + } + const isCancelled = ref(false); const cancelledMessage = ref(); @@ -101,6 +139,16 @@ export function useFetch( if (!abortController) { /* istanbul ignore else */ if (__DEV__) { + if (addTimelineEvent) { + addTimelineEvent( + Date.now(), + { message }, + { + type: "cancel_error", + error: "No request has been made yet", + } + ); + } throw new Error("Cannot cancel because no request has been made"); } else { return; @@ -109,17 +157,43 @@ export function useFetch( abortController.abort(); isCancelled.value = true; cancelledMessage.value = message; + + if (addTimelineEvent) { + addTimelineEvent( + Date.now(), + { message }, + { + type: "cancel", + } + ); + } }; const use = usePromise(async (request: RequestInfo, init?: RequestInit) => { abortController = new AbortController(); + if (addTimelineEvent) { + addTimelineEvent( + Date.now(), + isString(request) ? { url: request } : request, + { type: "request", init } + ); + } + const response = await fetch(request, { signal: abortController.signal, ...requestInit, - ...init + ...init, }); + if (addTimelineEvent) { + addTimelineEvent(Date.now(), response, { + type: "response", + init, + request, + }); + } + if (response) { const promises = [ // JSON @@ -127,8 +201,8 @@ export function useFetch( ? response .clone() .json() - .then(x => (json.value = x)) - .catch(x => { + .then((x) => (json.value = x)) + .catch((x) => { json.value = null; jsonError.value = x; }) @@ -137,7 +211,7 @@ export function useFetch( response .clone() .blob() - .then(x => { + .then((x) => { blob.value = x; }), @@ -145,12 +219,26 @@ export function useFetch( response .clone() .text() - .then(x => { + .then((x) => { text.value = x; - }) + }), ]; if (parseImmediate) { await Promise.all(promises); + + if (addTimelineEvent) { + addTimelineEvent( + Date.now(), + {}, + { + type: "parsed", + json: json.value, + blob: blob.value, + text: text.value, + request, + } + ); + } } } return response; @@ -192,6 +280,6 @@ export function useFetch( json, jsonError, status, - statusText + statusText, }; } diff --git a/readme.md b/readme.md index 6833f8964..aa06e603f 100644 --- a/readme.md +++ b/readme.md @@ -151,6 +151,103 @@ Check our [documentation](https://pikax.me/vue-composable/) This is a monorepo project, please check [packages](packages/) +## Devtools + +There's some experimental devtools support starting from `1.0.0-beta.6`, only available for `vue-next` and `devtools beta 6`. + +- [devtools beta chrome](https://chrome.google.com/webstore/detail/vuejs-devtools/ljjemllljcmogpfapbkkighbhhppjdbg) + +### Install plugin + +To use devtools you need to install the plugin first: + +```ts +import { createApp } from "vue"; +import { VueComposableDevtools } from "vue-composable"; +import App from "./App.vue"; + +const app = createApp(App); +app.use(VueComposableDevtools); +// or +app.use(VueComposableDevtools, { + id: "vue-composable", + label: "devtool composables", +}); + +app.mount("#app"); +``` + +### Timeline events + +To add timeline events: + +```ts +const id = "vue-composable"; +const label = "Test events"; +const color = 0x92a2bf; + +const { addEvent, pushEvent } = useDevtoolsTimelineLayer( + id, + description, + color +); + +// adds event to a specific point in the timeline +addEvent({ + time: Date.now(), + data: { + // data object + }, + meta: { + // meta object + }, +}); + +// adds event with `time: Date.now()` +pushEvent({ + data: { + // data object + }, + meta: { + // meta object + }, +}); +``` + +### Inspector + +Allows to create a new inpector for your data. + +> I'm still experimenting on how to expose this API on a composable, this will likely to change in the future, suggestions are welcome. + +```ts +useDevtoolsInpector( + { + id: "vue-composable", + label: "test vue-composable", + }, + // list of nodes, this can be reactive + [ + { + id: "test", + label: "test - vue-composable", + depth: 0, + state: { + composable: [ + { + editable: false, + key: "count", + objectType: "Ref", + type: "setup", + value: myRefValue, + }, + ], + }, + }, + ] +); +``` + ## Contributing You can contribute raising issues and by helping out with code. diff --git a/rollup.config.js b/rollup.config.js index 47d3a8382..9945c82fa 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -12,7 +12,7 @@ if (!process.env.TARGET) { const packagesDir = path.resolve(__dirname, "packages"); const packageDir = path.resolve(packagesDir, process.env.TARGET); const name = path.basename(packageDir); -const resolve = p => path.resolve(packageDir, p); +const resolve = (p) => path.resolve(packageDir, p); const pkg = require(resolve(`package.json`)); const packageOptions = pkg.buildOptions || {}; const vueVersion = process.env.VUE_VERSION; @@ -25,11 +25,11 @@ let hasTSChecked = false; const configs = { "esm-bundler": { file: resolve(`dist/v${vueVersion}/${name}.esm-bundler.js`), - format: `es` + format: `es`, }, cjs: { file: resolve(`dist/v${vueVersion}/${name}.cjs.js`), - format: `cjs` + format: `cjs`, }, global: { file: resolve(`dist/v${vueVersion}/${name}.global.js`), @@ -38,19 +38,25 @@ const configs = { "@vue/composition-api": "vueCompositionApi", "@vue/runtime-core": "VueRuntimeCore", axios: "axios", - vue: "Vue" - } + vue: "Vue", + }, }, esm: { file: resolve(`dist/v${vueVersion}/${name}.esm.js`), format: `es`, - external: ["vue", "@vue/composition-api", "axios"] - } + external: ["vue", "@vue/composition-api", "axios", "@vue/devtools-api"], + }, }; const setup = { global: { - external: ["vue", "@vue/composition-api", "axios", "@vue/runtime-core"], + external: [ + "vue", + "@vue/composition-api", + "axios", + "@vue/runtime-core", + "@vue/devtools-api", + ], plugins: [ resolvePlugin({ // mainFields: [ @@ -59,9 +65,9 @@ const setup = { // modulesOnly: true, // only: "@vue-composable/core", // dedupe: ["vue", "@vue/composition-api"] - }) - ] - } + }), + ], + }, }; const defaultFormats = ["esm-bundler", "cjs"]; @@ -70,7 +76,7 @@ const packageFormats = inlineFormats || packageOptions.formats || defaultFormats; const packageConfigs = process.env.PROD_ONLY ? [] - : packageFormats.map(format => + : packageFormats.map((format) => createConfig( configs[format], configs[format].plugins || [], @@ -79,7 +85,7 @@ const packageConfigs = process.env.PROD_ONLY ); if (process.env.NODE_ENV === "production") { - packageFormats.forEach(format => { + packageFormats.forEach((format) => { if (format === "cjs" && packageOptions.prod !== false) { packageConfigs.push(createProductionConfig(format)); } @@ -117,10 +123,10 @@ function createConfig(output, plugins = [], config = {}) { tsconfigOverride: { compilerOptions: { declaration: shouldEmitDeclarations, - declarationMap: shouldEmitDeclarations + declarationMap: shouldEmitDeclarations, }, - exclude: ["**/__tests__", "test-dts"] - } + exclude: ["**/__tests__", "test-dts"], + }, }); // we only need to check TS and generate declarations once for each build. // it also seems to run into weird issues when checking multiple times @@ -144,7 +150,7 @@ function createConfig(output, plugins = [], config = {}) { ...(config.plugins || []), json({ - namedExports: false + namedExports: false, }), tsPlugin, createReplacePlugin( @@ -154,9 +160,9 @@ function createConfig(output, plugins = [], config = {}) { !packageOptions.enableNonBrowserBranches, isRuntimeCompileBuild ), - ...plugins + ...plugins, ], - output + output, }; } @@ -191,7 +197,7 @@ function createReplacePlugin( ? `process.env.NODE_ENV` : "'production'", - __VUE_2__: process.env.VUE_VERSION === "2" + __VUE_2__: process.env.VUE_VERSION === "2", }); } @@ -199,7 +205,7 @@ function createProductionConfig(format) { return createConfig( { file: resolve(`dist/v${vueVersion}/${name}.${format}.prod.js`), - format: configs[format].format + format: configs[format].format, }, configs[format].plugins || [], setup[format] || {} @@ -212,13 +218,13 @@ function createMinifiedConfig(format) { { ...configs[format], file: resolve(`dist/v${vueVersion}/${name}.${format}.prod.js`), - format: configs[format].format + format: configs[format].format, }, [ ...(configs[format].plugins || []), terser({ - module: /^esm/.test(format) - }) + module: /^esm/.test(format), + }), ], setup[format] || {} ); diff --git a/yarn.lock b/yarn.lock index dacd46ec5..d245447c8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1624,6 +1624,11 @@ dependencies: tslib "^2.0.1" +"@vue/devtools-api@^6.0.0-beta.2": + version "6.0.0-beta.2" + resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.0.0-beta.2.tgz#833ad3335f97ae9439e26247d97f9baf7b5a6116" + integrity sha512-5k0A8ffjNNukOiceImBdx1e3W5Jbpwqsu7xYHiZVu9mn4rYxFztIt+Q25mOHm7nwvDnMHrE7u5KtY2zmd+81GA== + "@vue/reactivity@3.0.0-rc.10", "@vue/reactivity@^3.0.0-rc.4": version "3.0.0-rc.10" resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.0.0-rc.10.tgz#34d5f51bcc5a7c36e27d7a9c1bd7a3d25ffa7c56" @@ -8644,6 +8649,11 @@ prettier@^1.18.2: resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.19.1.tgz#f7d7f5ff8a9cd872a7be4ca142095956a60797cb" integrity sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew== +prettier@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.1.1.tgz#d9485dd5e499daa6cb547023b87a6cf51bee37d6" + integrity sha512-9bY+5ZWCfqj3ghYBLxApy2zf6m+NJo5GzmLTpr9FsApsfjriNnS2dahWReHMi7qNPhhHl9SYHJs2cHZLgexNIw== + pretty-bytes@^5.1.0: version "5.4.1" resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.4.1.tgz#cd89f79bbcef21e3d21eb0da68ffe93f803e884b"