diff --git a/docs/framework/angular/devtools.md b/docs/framework/angular/devtools.md index 1787544ca1..00f20b82d3 100644 --- a/docs/framework/angular/devtools.md +++ b/docs/framework/angular/devtools.md @@ -7,7 +7,7 @@ title: Devtools The devtools help you debug and inspect your queries and mutations. You can enable the devtools by adding `withDevtools` to `provideTanStackQuery`. -By default, the devtools are enabled when Angular [`isDevMode`](https://angular.dev/api/core/isDevMode) returns true. So you don't need to worry about excluding them during a production build. The core tools are lazily loaded and excluded from bundled code. In most cases, all you'll need to do is add `withDevtools()` to `provideTanStackQuery` without any additional configuration. +By default, the devtools are enabled when Angular is in development mode. So you don't need to worry about excluding them during a production build. The tools are lazily loaded and excluded from bundled code. In most cases, all you'll need to do is add `withDevtools()` to `provideTanStackQuery` without any additional configuration. ```ts import { @@ -61,7 +61,7 @@ Using this technique allows you to support on-demand loading of the devtools eve ```ts @Injectable({ providedIn: 'root' }) -class DevtoolsOptionsManager { +export class DevtoolsOptionsManager { loadDevtools = toSignal( fromEvent(document, 'keydown').pipe( map( @@ -81,10 +81,15 @@ export const appConfig: ApplicationConfig = { provideHttpClient(), provideTanStackQuery( new QueryClient(), - withDevtools(() => ({ - initialIsOpen: true, - loadDevtools: inject(DevtoolsOptionsManager).loadDevtools(), - })), + withDevtools( + (devToolsOptionsManager: DevtoolsOptionsManager) => ({ + loadDevtools: devToolsOptionsManager.loadDevtools(), + }), + { + // `deps` can be used to pass one or more injectables as parameters to the `withDevtools` callback. + deps: [DevtoolsOptionsManager], + }, + ), ), ], } @@ -92,7 +97,7 @@ export const appConfig: ApplicationConfig = { ### Options -Of these options `client`, `position`, `errorTypes`, `buttonPosition`, and `initialIsOpen` support reactivity through signals. +Of these options `loadDevtools`, `client`, `position`, `errorTypes`, `buttonPosition`, and `initialIsOpen` support reactivity through signals. - `loadDevtools?: 'auto' | boolean` - Defaults to `auto`: lazily loads devtools when in development mode. Skips loading in production mode. diff --git a/packages/angular-query-experimental/src/__tests__/providers.test.ts b/packages/angular-query-experimental/src/__tests__/providers.test.ts index 266aa40a35..41755ab99c 100644 --- a/packages/angular-query-experimental/src/__tests__/providers.test.ts +++ b/packages/angular-query-experimental/src/__tests__/providers.test.ts @@ -1,25 +1,24 @@ -import { beforeEach, describe, expect, test, vi } from 'vitest' +import { beforeEach, describe, expect, it, test, vi } from 'vitest' import { QueryClient } from '@tanstack/query-core' import { TestBed } from '@angular/core/testing' import { ENVIRONMENT_INITIALIZER, + PLATFORM_ID, provideExperimentalZonelessChangeDetection, signal, } from '@angular/core' -import { isDevMode } from '../util/is-dev-mode/is-dev-mode' -import { provideTanStackQuery, withDevtools } from '../providers' +import { + QueryFeatureKind, + provideTanStackQuery, + withDevtools, +} from '../providers' import type { DevtoolsOptions } from '../providers' -import type { Mock } from 'vitest' import type { DevtoolsButtonPosition, DevtoolsErrorType, DevtoolsPosition, } from '@tanstack/query-devtools' -vi.mock('../util/is-dev-mode/is-dev-mode', () => ({ - isDevMode: vi.fn(), -})) - const mockDevtoolsInstance = { mount: vi.fn(), unmount: vi.fn(), @@ -37,104 +36,170 @@ vi.mock('@tanstack/query-devtools', () => ({ })) describe('withDevtools feature', () => { - let isDevModeMock: Mock - beforeEach(() => { vi.useFakeTimers() - isDevModeMock = isDevMode as Mock }) afterEach(() => { vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + describe('tree shaking', () => { + it('should return empty providers when ngDevMode and withDevtoolsFn are undefined', () => { + vi.stubGlobal('ngDevMode', undefined) + const feature = withDevtools() + expect(feature.ɵkind).toEqual(QueryFeatureKind.DeveloperTools) + expect(feature.ɵproviders.length).toEqual(0) + }) + + it('should return providers when ngDevMode is undefined and withDevtoolsFn is defined', () => { + vi.stubGlobal('ngDevMode', undefined) + const feature = withDevtools(() => ({})) + expect(feature.ɵkind).toEqual(QueryFeatureKind.DeveloperTools) + expect(feature.ɵproviders.length).toBeGreaterThan(0) + }) + + it('should return providers when ngDevMode is defined and withDevtoolsFn is undefined', () => { + vi.stubGlobal('ngDevMode', {}) + const feature = withDevtools() + expect(feature.ɵkind).toEqual(QueryFeatureKind.DeveloperTools) + expect(feature.ɵproviders.length).toBeGreaterThan(0) + }) }) test.each([ { description: 'should provide developer tools in development mode by default', - isDevModeValue: true, + isDevMode: true, expectedCalled: true, }, { description: 'should not provide developer tools in production mode by default', - isDevModeValue: false, + isDevMode: false, expectedCalled: false, }, { description: `should provide developer tools in development mode when 'loadDeveloperTools' is set to 'auto'`, - isDevModeValue: true, + isDevMode: true, loadDevtools: 'auto', expectedCalled: true, }, { description: `should not provide developer tools in production mode when 'loadDeveloperTools' is set to 'auto'`, - isDevModeValue: false, + isDevMode: false, loadDevtools: 'auto', expectedCalled: false, }, { description: "should provide developer tools in development mode when 'loadDevtools' is set to true", - isDevModeValue: true, + isDevMode: true, loadDevtools: true, expectedCalled: true, }, { description: "should provide developer tools in production mode when 'loadDevtools' is set to true", - isDevModeValue: false, + isDevMode: false, loadDevtools: true, expectedCalled: true, }, { description: "should not provide developer tools in development mode when 'loadDevtools' is set to false", - isDevModeValue: true, + isDevMode: true, loadDevtools: false, expectedCalled: false, }, { description: "should not provide developer tools in production mode when 'loadDevtools' is set to false", - isDevModeValue: false, + isDevMode: false, loadDevtools: false, expectedCalled: false, }, - ])( - '$description', - async ({ isDevModeValue, loadDevtools, expectedCalled }) => { - isDevModeMock.mockReturnValue(isDevModeValue) + ])('$description', async ({ isDevMode, loadDevtools, expectedCalled }) => { + vi.stubGlobal('ngDevMode', isDevMode ? {} : undefined) + + const providers = [ + provideExperimentalZonelessChangeDetection(), + provideTanStackQuery( + new QueryClient(), + loadDevtools !== undefined + ? withDevtools( + () => + ({ + loadDevtools, + }) as DevtoolsOptions, + ) + : withDevtools(), + ), + ] + + TestBed.configureTestingModule({ + providers, + }) + + TestBed.inject(ENVIRONMENT_INITIALIZER) + await vi.runAllTimersAsync() + TestBed.flushEffects() + await vi.dynamicImportSettled() + TestBed.flushEffects() + await vi.dynamicImportSettled() + + if (expectedCalled) { + expect(mockTanstackQueryDevtools).toHaveBeenCalled() + } else { + expect(mockTanstackQueryDevtools).not.toHaveBeenCalled() + } + }) - const providers = [ + it('should not load devtools if injector is destroyed', async () => { + TestBed.configureTestingModule({ + providers: [ provideExperimentalZonelessChangeDetection(), provideTanStackQuery( new QueryClient(), - loadDevtools !== undefined - ? withDevtools( - () => - ({ - loadDevtools, - }) as DevtoolsOptions, - ) - : withDevtools(), + withDevtools(() => ({ + loadDevtools: true, + })), ), - ] + ], + }) - TestBed.configureTestingModule({ - providers, - }) + TestBed.inject(ENVIRONMENT_INITIALIZER) + // Destroys injector + TestBed.resetTestingModule() + await vi.runAllTimersAsync() - TestBed.inject(ENVIRONMENT_INITIALIZER) - await vi.runAllTimersAsync() + expect(mockTanstackQueryDevtools).not.toHaveBeenCalled() + }) - if (expectedCalled) { - expect(mockTanstackQueryDevtools).toHaveBeenCalled() - } else { - expect(mockTanstackQueryDevtools).not.toHaveBeenCalled() - } - }, - ) + it('should not load devtools if platform is not browser', async () => { + TestBed.configureTestingModule({ + providers: [ + { + provide: PLATFORM_ID, + useValue: 'server', + }, + provideExperimentalZonelessChangeDetection(), + provideTanStackQuery( + new QueryClient(), + withDevtools(() => ({ + loadDevtools: true, + })), + ), + ], + }) + + TestBed.inject(ENVIRONMENT_INITIALIZER) + await vi.runAllTimersAsync() + + expect(mockTanstackQueryDevtools).not.toHaveBeenCalled() + }) it('should update error types', async () => { const errorTypes = signal([] as Array) diff --git a/packages/angular-query-experimental/src/devtools-setup.ts b/packages/angular-query-experimental/src/devtools-setup.ts new file mode 100644 index 0000000000..231535fca1 --- /dev/null +++ b/packages/angular-query-experimental/src/devtools-setup.ts @@ -0,0 +1,83 @@ +import { DestroyRef, computed, effect } from '@angular/core' +import { QueryClient, onlineManager } from '@tanstack/query-core' +import type { Injector, Signal } from '@angular/core' +import type { TanstackQueryDevtools } from '@tanstack/query-devtools' +import type { DevtoolsOptions } from './providers' + +declare const ngDevMode: unknown + +// This function is lazy loaded to speed up up the initial load time of the application +// and to minimize bundle size +export function setupDevtools( + injector: Injector, + devtoolsOptions: Signal, +) { + const isDevMode = typeof ngDevMode !== 'undefined' && ngDevMode + const injectedClient = injector.get(QueryClient, { + optional: true, + }) + const destroyRef = injector.get(DestroyRef) + + let devtools: TanstackQueryDevtools | null = null + let el: HTMLElement | null = null + + const shouldLoadToolsSignal = computed(() => { + const { loadDevtools } = devtoolsOptions() + return typeof loadDevtools === 'boolean' ? loadDevtools : isDevMode + }) + + const getResolvedQueryClient = () => { + const client = devtoolsOptions().client ?? injectedClient + if (!client) { + throw new Error('No QueryClient found') + } + return client + } + + const destroyDevtools = () => { + devtools?.unmount() + el?.remove() + devtools = null + } + + effect( + () => { + const shouldLoadTools = shouldLoadToolsSignal() + const { client, position, errorTypes, buttonPosition, initialIsOpen } = + devtoolsOptions() + + if (devtools && !shouldLoadTools) { + destroyDevtools() + return + } else if (devtools && shouldLoadTools) { + client && devtools.setClient(client) + position && devtools.setPosition(position) + errorTypes && devtools.setErrorTypes(errorTypes) + buttonPosition && devtools.setButtonPosition(buttonPosition) + initialIsOpen && devtools.setInitialIsOpen(initialIsOpen) + return + } else if (!shouldLoadTools) { + return + } + + el = document.body.appendChild(document.createElement('div')) + el.classList.add('tsqd-parent-container') + + import('@tanstack/query-devtools').then((queryDevtools) => { + devtools = new queryDevtools.TanstackQueryDevtools({ + ...devtoolsOptions(), + client: getResolvedQueryClient(), + queryFlavor: 'Angular Query', + version: '5', + onlineManager, + }) + + el && devtools.mount(el) + + // Unmount the devtools on application destroy + destroyRef.onDestroy(destroyDevtools) + }) + }, + { injector }, + ) +} diff --git a/packages/angular-query-experimental/src/providers.ts b/packages/angular-query-experimental/src/providers.ts index 98ea2e04ed..c1b2f82b1f 100644 --- a/packages/angular-query-experimental/src/providers.ts +++ b/packages/angular-query-experimental/src/providers.ts @@ -1,24 +1,32 @@ import { DestroyRef, ENVIRONMENT_INITIALIZER, + InjectionToken, + Injector, PLATFORM_ID, computed, - effect, inject, makeEnvironmentProviders, } from '@angular/core' -import { QueryClient, onlineManager } from '@tanstack/query-core' +import { QueryClient } from '@tanstack/query-core' import { isPlatformBrowser } from '@angular/common' -import { isDevMode } from './util/is-dev-mode/is-dev-mode' import { noop } from './util' -import type { EnvironmentProviders, Provider } from '@angular/core' +import type { EnvironmentProviders, Provider, Signal } from '@angular/core' import type { DevtoolsButtonPosition, DevtoolsErrorType, DevtoolsPosition, - TanstackQueryDevtools, } from '@tanstack/query-devtools' +declare const ngDevMode: unknown + +/** + * @internal + */ +const DEVTOOLS_OPTIONS_SIGNAL = new InjectionToken>( + 'devtools options signal', +) + /** * Usually {@link provideTanStackQuery} is used once to set up TanStack Query and the * {@link https://tanstack.com/query/latest/docs/reference/QueryClient|QueryClient} @@ -153,14 +161,46 @@ export function queryFeature( * @public * @see {@link withDevtools} */ -export type DeveloperToolsFeature = QueryFeature<'DeveloperTools'> +export type DeveloperToolsFeature = + QueryFeature /** * A type alias that represents a feature which enables persistence. * The type is used to describe the return value of the `withPersistQueryClient` function. * @public */ -export type PersistQueryClientFeature = QueryFeature<'PersistQueryClient'> +export type PersistQueryClientFeature = + QueryFeature + +/** + * Options for configuring withDevtools. + * @public + */ +export interface WithDevtoolsOptions { + /** + * An array of dependencies to be injected and passed to the `withDevtoolsFn` function. + * + * **Example** + * ```ts + * export const appConfig: ApplicationConfig = { + * providers: [ + * provideTanStackQuery( + * new QueryClient(), + * withDevtools( + * (devToolsOptionsManager: DevtoolsOptionsManager) => ({ + * loadDevtools: devToolsOptionsManager.loadDevtools(), + * }), + * { + * deps: [DevtoolsOptionsManager], + * }, + * ), + * ), + * ], + * } + * ``` + */ + deps?: Array +} /** * Options for configuring the TanStack Query devtools. @@ -241,104 +281,48 @@ export interface DevtoolsOptions { * * If you need more control over where devtools are rendered, consider `injectDevtoolsPanel`. This allows rendering devtools inside your own devtools for example. * @param withDevtoolsFn - A function that returns `DevtoolsOptions`. + * @param options - Additional options for configuring `withDevtools`. * @returns A set of providers for use with `provideTanStackQuery`. * @public * @see {@link provideTanStackQuery} * @see {@link DevtoolsOptions} */ export function withDevtools( - withDevtoolsFn?: () => DevtoolsOptions, + withDevtoolsFn?: (...deps: Array) => DevtoolsOptions, + options: WithDevtoolsOptions = {}, ): DeveloperToolsFeature { let providers: Array = [] - if (!isDevMode() && !withDevtoolsFn) { - providers = [] + if (withDevtoolsFn === undefined && typeof ngDevMode === 'undefined') { + return queryFeature(QueryFeatureKind.DeveloperTools, providers) } else { providers = [ + { + provide: DEVTOOLS_OPTIONS_SIGNAL, + useFactory: (...deps: Array) => + computed(() => withDevtoolsFn?.(...deps) ?? {}), + deps: options.deps || [], + }, { // Do not use provideEnvironmentInitializer while Angular < v19 is supported provide: ENVIRONMENT_INITIALIZER, multi: true, - useFactory: () => { + useFactory: (devtoolsOptionsSignal: Signal) => { if (!isPlatformBrowser(inject(PLATFORM_ID))) return noop - const injectedClient = inject(QueryClient, { - optional: true, - }) - const destroyRef = inject(DestroyRef) - - const options = computed(() => withDevtoolsFn?.() ?? {}) - - let devtools: TanstackQueryDevtools | null = null - let el: HTMLElement | null = null - - const shouldLoadToolsSignal = computed(() => { - const { loadDevtools } = options() - return typeof loadDevtools === 'boolean' - ? loadDevtools - : isDevMode() - }) - - const getResolvedQueryClient = () => { - const client = options().client ?? injectedClient - if (!client) { - throw new Error('No QueryClient found') - } - return client - } - - const destroyDevtools = () => { - devtools?.unmount() - el?.remove() - devtools = null - } + let destroyed = false + const injector = inject(Injector) + inject(DestroyRef).onDestroy(() => (destroyed = true)) return () => - effect(() => { - const shouldLoadTools = shouldLoadToolsSignal() - const { - client, - position, - errorTypes, - buttonPosition, - initialIsOpen, - } = options() - - if (devtools && !shouldLoadTools) { - destroyDevtools() - return - } else if (devtools && shouldLoadTools) { - client && devtools.setClient(client) - position && devtools.setPosition(position) - errorTypes && devtools.setErrorTypes(errorTypes) - buttonPosition && devtools.setButtonPosition(buttonPosition) - initialIsOpen && devtools.setInitialIsOpen(initialIsOpen) - return - } else if (!shouldLoadTools) { - return - } - - el = document.body.appendChild(document.createElement('div')) - el.classList.add('tsqd-parent-container') - - import('@tanstack/query-devtools').then((queryDevtools) => { - devtools = new queryDevtools.TanstackQueryDevtools({ - ...options(), - client: getResolvedQueryClient(), - queryFlavor: 'Angular Query', - version: '5', - onlineManager, - }) - - el && devtools.mount(el) - - // Unmount the devtools on application destroy - destroyRef.onDestroy(destroyDevtools) - }) + import('./devtools-setup').then((module) => { + !destroyed && + module.setupDevtools(injector, devtoolsOptionsSignal) }) }, + deps: [DEVTOOLS_OPTIONS_SIGNAL], }, ] } - return queryFeature('DeveloperTools', providers) + return queryFeature(QueryFeatureKind.DeveloperTools, providers) } /** @@ -351,6 +335,7 @@ export function withDevtools( */ export type QueryFeatures = DeveloperToolsFeature | PersistQueryClientFeature -export const queryFeatures = ['DeveloperTools', 'PersistQueryClient'] as const - -export type QueryFeatureKind = (typeof queryFeatures)[number] +export enum QueryFeatureKind { + DeveloperTools, + PersistQueryClient, +} diff --git a/packages/angular-query-experimental/src/util/is-dev-mode/is-dev-mode.ts b/packages/angular-query-experimental/src/util/is-dev-mode/is-dev-mode.ts deleted file mode 100644 index 5c18cfcf51..0000000000 --- a/packages/angular-query-experimental/src/util/is-dev-mode/is-dev-mode.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Re-export for mocking in tests - -export { isDevMode } from '@angular/core' diff --git a/packages/angular-query-persist-client/src/with-persist-query-client.ts b/packages/angular-query-persist-client/src/with-persist-query-client.ts index 2896cbc7b2..16ef8cf8ef 100644 --- a/packages/angular-query-persist-client/src/with-persist-query-client.ts +++ b/packages/angular-query-persist-client/src/with-persist-query-client.ts @@ -1,5 +1,6 @@ import { QueryClient, + QueryFeatureKind, provideIsRestoring, queryFeature, } from '@tanstack/angular-query-experimental' @@ -84,5 +85,5 @@ export function withPersistQueryClient( }, }, ] - return queryFeature('PersistQueryClient', providers) + return queryFeature(QueryFeatureKind.PersistQueryClient, providers) }