Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Expose more models to the public API: SignalStoreFeatureResult and InputSignalStore<Input> #4272

Closed
1 of 2 tasks
GuillaumeNury opened this issue Mar 6, 2024 · 5 comments · Fixed by #4441
Closed
1 of 2 tasks

Comments

@GuillaumeNury
Copy link

GuillaumeNury commented Mar 6, 2024

Which @ngrx/* package(s) are relevant/related to the feature request?

signals

Information

In order to add more complexe features, it would be great to expose more models to the public API.

Here is an example of what I try to achieve

export const ExampleStore = signalStore(
  withState<ExampleState>({ field: 12 }),
  withMethods(
    (store) => ({
      log(text: string): void {
        console.log(text)
      },
    }),
  ),
  withTabVisibility({
    onTabVisible: (store) => store.log('tab is visible: ' + store.field())
  }),
});

It is possible but I used many unexposed models:

import { SignalStoreFeature, StateSignal } from '@ngrx/signals';
import type {
  EmptyFeatureResult,
  SignalStoreFeatureResult,
  SignalStoreSlices,
} from '@ngrx/signals/src/signal-store-models';

type Prettify<T> = {
  [K in keyof T]: T[K];
} & {};

type TabVisibilityTrigger<Input extends SignalStoreFeatureResult> = (
  store: Prettify<
    SignalStoreSlices<Input['state']> &
      Input['signals'] &
      Input['methods'] &
      StateSignal<Prettify<Input['state']>>
  >,
) => void;

export function withTabVisibility<
  Input extends SignalStoreFeatureResult,
>(options: {
  onTabVisible?: TabVisibilityTrigger<Input>;
  onTabHidden?: TabVisibilityTrigger<Input>;
}): SignalStoreFeature<Input, EmptyFeatureResult> {
  return (store) => {
    const inputStore = {
      ...store, // Cannot use [STATE_SIGNAL] because it's not exposed
      ...store.slices,
      ...store.signals,
      ...store.methods,
    };
    return {
      ...store,
      hooks: {
        onInit: () => {
          store.hooks.onInit?.();
          console.log('onInit');
          document.addEventListener('visibilitychange', () =>
            document.hidden
              ? options.onTabHidden?.(inputStore)
              : options.onTabVisible?.(inputStore),
          );
        },
      },
    };
  };
}

What would be marvelous would be to:

  • Expose EmptyFeatureResult and SignalStoreFeatureResult
  • Create a type SignalStoreFeatureInputStore<Input> (could be used by withMethods and withHooks)
  • Create a function createFeatureInputStore (could be used by withMethods and withHooks as well)

I could have written:

import { SignalStoreFeature, StateSignal, createFeatureInputStore, SignalStoreFeatureResult, EmptyFeatureResult, SignalStoreFeatureInputStore } from '@ngrx/signals';

type TabVisibilityTrigger<Input extends SignalStoreFeatureResult> = (
  store: SignalStoreFeatureInputStore<Input>,
) => void;

export function withTabVisibility<
  Input extends SignalStoreFeatureResult,
>(options: {
  onTabVisible?: TabVisibilityTrigger<Input>;
  onTabHidden?: TabVisibilityTrigger<Input>;
}): SignalStoreFeature<Input, EmptyFeatureResult> {
  return (store) => {
    const inputStore = createFeatureInputStore(store);
    return {
      ...store,
      hooks: {
        onInit: () => {
          store.hooks.onInit?.();
          console.log('onInit');
          document.addEventListener('visibilitychange', () =>
            document.hidden
              ? options.onTabHidden?.(inputStore)
              : options.onTabVisible?.(inputStore),
          );
        },
      },
    };
  };
}

Thanks for this awesome lib ❤️

Describe any alternatives/workarounds you're currently using

It is possible to add hooks with:

const withTabVisiblity = signalStoreFeature(
    {
      methods: type<Partial<{ onTabVisible(): void; onTabHidden(): void }>>(),
    }
    // ...
)

Then:

export const ExampleStore = signalStore(
  withState<ExampleState>({}),
  withMethods(
    (store) => ({
      onTabVisible(): void {
        console.log('tab is visible')
      },
    }),
  ),
  withTabVisibility(),

The onTabVisible method seems completely decoupled from the withTabVisibility feature.

I would be willing to submit a PR to fix this issue

  • Yes
  • No
@markostanimirovic
Copy link
Member

markostanimirovic commented Jul 14, 2024

Hi @GuillaumeNury,

I agree that we need more types in the public API to support advanced use cases such as this one. However, it's important to mention that this is not the right way of building custom features. We should not reimplement the logic of base features, but use them instead because the internal logic can change in the meantime.

This is how withTabVisibility feature should look like:

type TabVisibilityMethod<Input extends SignalStoreFeatureResult> = (
  store: Prettify<
    StateSignals<Input['state']> &
      Input['computed'] &
      Input['methods'] &
      StateSource<Input['state']>
  >
) => void;

export function withTabVisibility<
  Input extends SignalStoreFeatureResult
>(methods: {
  onTabVisible?: TabVisibilityMethod<Input>;
  onTabHidden?: TabVisibilityMethod<Input>;
}): SignalStoreFeature<Input, { state: {}; computed: {}; methods: {} }> {
  return signalStoreFeature(
    type<Input>(),
    withHooks((store) => {
      const visibilityChangeFn = () => {
        document.hidden
          ? methods.onTabHidden?.(store)
          : methods.onTabVisible?.(store);
      };

      return {
        onInit() {
          document.addEventListener('visibilitychange', visibilityChangeFn);
        },
        onDestroy() {
          document.removeEventListener('visibilitychange', visibilityChangeFn);
        },
      };
    })
  );
}

// Usage:

const CounterStore = signalStore(
  withState({ count: 0 }),
  withMethods((store) => ({
    increment(): void {
      patchState(store, { count: store.count() + 1 });
    },
    decrement(): void {
      patchState(store, { count: store.count() - 1 });
    },
  })),
  withTabVisibility({
    onTabVisible: ({ increment }) => increment(),
    onTabHidden: ({ decrement }) => decrement(),
  })
);

The following models are missing from the public API to implement features such as this one:

  • SignalStoreFeatureResult
  • StateSignals
  • Prettify

They will be added to the public API.

@GuillaumeNury
Copy link
Author

@markostanimirovic thanks for your reply!

I cannot find what StateSource is.

Would it be better to expose SignalStoreFeatureResult, StateSignals, Prettify and StateSource or SignalStoreFeatureResult and a new type:

// This type avoid exposing "utils" like Prettify
export type SignalStoreFeatureInputStore<Input extends SignalStoreFeatureResult> = Prettify<
    StateSignals<Input['state']> &
    Input['computed'] &
    Input['methods'] &
    StateSource<Input['state']>
>

The previous implementation would become:

type TabVisibilityMethod<Input extends SignalStoreFeatureResult> = (
  store: SignalStoreFeatureInputStore<Input>
) => void;

export function withTabVisibility<
  Input extends SignalStoreFeatureResult
>(methods: {
  onTabVisible?: TabVisibilityMethod<Input>;
  onTabHidden?: TabVisibilityMethod<Input>;
}): SignalStoreFeature<Input, { state: {}; computed: {}; methods: {} }> {
  return signalStoreFeature(
    type<Input>(),
    withHooks((store) => {
      const visibilityChangeFn = () => {
        document.hidden
          ? methods.onTabHidden?.(store)
          : methods.onTabVisible?.(store);
      };

      return {
        onInit() {
          document.addEventListener('visibilitychange', visibilityChangeFn);
        },
        onDestroy() {
          document.removeEventListener('visibilitychange', visibilityChangeFn);
        },
      };
    })
  );
}

// Usage:

const CounterStore = signalStore(
  withState({ count: 0 }),
  withMethods((store) => ({
    increment(): void {
      patchState(store, { count: store.count() + 1 });
    },
    decrement(): void {
      patchState(store, { count: store.count() - 1 });
    },
  })),
  withTabVisibility({
    onTabVisible: ({ increment }) => increment(),
    onTabHidden: ({ decrement }) => decrement(),
  })
);

Anyway, thanks for the type<Input>() trick!

@markostanimirovic
Copy link
Member

I cannot find what StateSource is.

StateSignal will be renamed to StateSource in v18. #4424

Would it be better to expose SignalStoreFeatureResult, StateSignals, Prettify and StateSource or SignalStoreFeatureResult and a new type:

It will be useful for features that need access to all store members. However, there may be a case where you want to restrict access and expose e.g. only state and computed signals:

type MyCustomFeatureMethod<Input extends SignalStoreFeatureResult> = (
  store: Prettify<StateSignals<Input['state']> & Input['computed']>
) => void;

Therefore, types are separated for better flexibility.

@GuillaumeNury
Copy link
Author

@markostanimirovic Ok, makes sense!

@GuillaumeNury
Copy link
Author

@markostanimirovic EmptyFeatureResult would be useful too

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants