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

Lifetime flexibility #2

Open
Serg046 opened this issue Nov 1, 2024 · 10 comments
Open

Lifetime flexibility #2

Serg046 opened this issue Nov 1, 2024 · 10 comments

Comments

@Serg046
Copy link

Serg046 commented Nov 1, 2024

The current public behaviour is singletons only. It doesn't work for quite some scenarios. There is a workaround like this:

class ServiceProvider extends dicc.ServiceProvider {
    get(id: string) {
        return (this as any).create(id);
    }
}

but then you always have transient life scope. If you want to have singleton lifetime for some services, you have to continue modifying this 'get' overloading instead of modifying the place of the service registration (which is the service itself in our case). I'd suggest doing something like the following:

  1. Making create(...) method public.
  2. Adding some marker (annotation?) to explain for the compiler whether you want singleton or transient (or something else if it is added in future).
@jahudka
Copy link
Collaborator

jahudka commented Nov 1, 2024

Lifetime of services is managed using the scope option, which you can set on each explicit service or decorator definition. Aside from global singleton services (which is the default), you can set services to be private, which is the polar opposite - every time a private service is requested from the container, you get a new instance. The middle ground is the local scope, which works in tandem with the container.fork(callback) method - local services can only be instantiated inside callback, and they are singletons inside the callback. This is what AsyncLocalStorage is used for in the container - thanks to it, the container can track the async execution chain started from the callback and thus inject the correct instances of locally-scoped services. Of course, if a browser version of the runtime DICC implementation is implemented as discussed in #1, that wouldn't allow the local scope, because there is no AsyncLocalStorage in the browser right now.

Do you have another use-case that isn't covered by these options? And if so, how would you like it to work?

@Serg046
Copy link
Author

Serg046 commented Nov 14, 2024

Thank you @jahudka, this scope option works fine. The naming is just unusual that's why I was confused. The only wish here is to have an ability to override a default behavior from global to private. I can imagine many use cases, below is one of them that I will get soon.

I have a web components based app. All the components and services are expected to be registered via dicc. Here is a hack for web components (as they require parameterless constructors). And here I use dicc injected dependency to instantiate a web component. This time a singleton is fine because I actually inject a factory, not a dependency, as I need multiple TableRow components. But if I needed just one new TableRow component, I'd then inject just TableRow instead of () => TableRow.

@jahudka
Copy link
Collaborator

jahudka commented Nov 15, 2024

Well it isn't too hard to add an option to the DICC config file which would allow you to override the default service scope and I don't see a reason not to do it, so I'll just do it within, say, a day or two.

Looking at your example code, I see the problem.. you want constructor injection in web components, which don't allow constructor arguments. Except... they kinda do, you just can't create them using document.createElement() then - but you can still create regular instances using new. See this JSFiddle: https://jsfiddle.net/soc28wzb/

The point is that you can just write your components as regular classes without the defineComponent() wrapper, register them as privately-scoped services in your DICC container and then let DICC create the instances for you, instead of using document.createElement(). Of course, I'm saying this as someone who literally never used web components and just threw this together with three MDN WebComponents tutorials open on the side.. but it does work.

As a side note, if all your web components extend from, say, HTMLElement (even indirectly, at least if the TypeScript DOM library is written using regular classes and extends), then you can make all of them private by using a "service decorator", like this:

import { ServiceDecorator } from 'dicc';

// just export this from one of your resource files:
export const makeWebComponentsPrivate = {
  scope: 'private',
} satisfies ServiceDecorator<HTMLElement>;

And you can also use an auto-implemented factory in order to be able to pass some arguments to the component's constructor at the call site, on top of injecting services, like this:

export class Counter {
  constructor(state: CounterState, color: string) {
    // ...
  }
}

export interface CounterFactory {
  create(color: string): Counter;
}

And then inject CounterFactory wherever you need to create an instance of Counter; DICC will internally implement that interface so that the state argument is injected and the color argument is passed through from the create() method of the factory. If you do it this way, you don't even need to make Counter private, because it will happen automatically.

@Serg046
Copy link
Author

Serg046 commented Nov 15, 2024

Thanks for your effort to give a suggestion. I am not just "someone who literally never used web components", I am not even a frontend guy 😄. The thing is that I want to keep a possibility to use markup, see https://github.com/skyscrapercity-ru/skyscrapers/blob/main/src/index.html#L10. So, I now have all the components registered in dicc from one hand, and a possibility to use them in markup from the other.

Btw, document.createElement() is not really needed, it was there temporary.
skyscrapercity-ru/skyscrapers@815ec3b

@jahudka
Copy link
Collaborator

jahudka commented Nov 15, 2024

Okay, so I have a potential solution: let's say you start out with a service class like this:

export abstract class CounterComponent extends HTMLElement {
  protected constructor(
    private readonly state: CounterState,
    private readonly anotherDep: AnotherDep,
  ) {
    super();
  }
}

Then DICC could compile a service factory like this:

// in the generated factory map:
'#CounterComponent0.0': {
  factory: (di) => class extends CounterComponent {  // notice no "new" keyword
    constructor() {
      super(di.get('#CounterState0.0'), di.get('#AnotherDep0.0')); // inject dependencies via super() call
    }
  },
  onCreate: (service) => {
    // when the service is first created, register the custom element:
    window.customElements.define('counter-component', service); 
  },
},

An entrypoint service of your application could then depend on HTMLElement[] in order to ensure all WebComponent class services are created and registered in the global customElements registry. Then you could use the components in markup, and in code you'd be injecting the class which you'd then be able to instantiate as needed:

export class CounterList {
  constructor(private readonly Counter: CounterComponent) {}

  // later when you need it:
  render() {
    const counter = new this.Counter(); // no args; even document.createElement('...') would work
  }
}

How does that sound? You cold code this manually for each component using factory functions, but that'd be cumbersome.. so if this looks like it would work, I'll look into having a way for DICC to do that, possibly via an extension.

@Serg046
Copy link
Author

Serg046 commented Nov 15, 2024

The feature looks very useful for my scenario but not really sure if this is useful enough for the community. I am now forced to do similar manually in the plugin, you can find it here. And yes, this looks quite crazy 😄.

As for onCreate hook, my current approach looks better to me for now. First of all, you have to define onCreate for every single component rather than doing it one time somewhere. Secondly, seems the service passed as a parameter to onCreate hook is an instance while customElement has to be a constructor.

P.S. Ability to change settings defaults looks useful regardless of what could be done to help with my case. I'd say that having transient/private as a default behaviour is a frequent wish.

@jahudka
Copy link
Collaborator

jahudka commented Nov 15, 2024

uh... yeah, that does look wild 😂 plus I don't think it's gonna work now, the source code and structure changed quite dramatically in the new version..

anyway, basically, DICC already does most of the heavy lifting, you just need to persuade it a little bit to get it to do what you need..

services don't necessarily have to be class instances - they can be almost anything that you can give a sufficiently unique type to, and then you can just use a service factory instead of a class constructor to create an instance of the service (which is not necessarily the same thing as an instance of a class)

so you can have a factory function which returns a class, e.g. like this:

// components/counter.ts
export class Counter extends HTMLElement {
  constructor(private readonly counterState: CounterState) {}
}

// components/types.ts
export interface ComponentClass<C extends HTMLElement> {
  new (): C;
}

// definitions.ts
// factory function for an injected Counter component *class*:
export function createCounterClass(
  getCounterState: () => CounterState, // define dependencies using accessors
): ComponentClass<Counter> { // return a *class*, not an instance
  return class extends Counter {
    constructor() {
      super(getCounterState());
    }
  };
}

Then put definitions.ts in your dicc.yaml resources instead of the component file itself. Now the service is not an instance of the Counter class, it is instead a constructor for a child class of Counter. If you add an onCreate() hook, then the value the hook will receive is the class, not an instance of it, because DICC doesn't even know or care that the service itself is instantiable and it will never try to instantiate it.

And you don't have to register an onCreate() hook for each component separately: you can use a service decorator to add a hook to all services matching a given type. Unfortunately, DICC doesn't yet work very well with generic types, so the ComponentClass interface would either have to not be generic, or it would have to extend a base non-generic interface, otherwise you wouldn't be able to target it using a service decorator. But the onCreate hook would then receive the constructor of each component, and so it could call window.customElements.define(service.componentName, service).

But of course the fact remains that you'd still have to write all the factory functions manually.. which is what I was proposing to implement as an extension to DICC, ie. a plugin / addon / optional thing that people either use or don't use according to their use case.

@jahudka
Copy link
Collaborator

jahudka commented Nov 16, 2024

Ofc if your component services are classes and not instances of classes, then you don't need to make them private / transient (and in fact you probably shouldn't, otherwise things like instanceof might stop working in some cases); you'd have to create the instances yourself as needed. But it would work with both document.createElement() and with markup.

Btw I just found out that you can do some neat tricks with mapped tuple types - e.g. you can easily convert Parameters<typeof someFunction>, which is a tuple of the actual parameter types, into a tuple of callbacks which return those types - i.e., accessors. So I'm thinking I could add tuples as a thing that DICC can inject.. in which case you wouldn't even need to repeat the original component constructor's arguments in the factory function - your component service definitions could simply be something like:

export const createCounterClass = createComponentClassFactory(Counter, 'counter-component');

The createComponentClassFactory() function would basically do almost exactly what your defineComponent function did, except not directly - it would return a function which would do it, like this:

export type Accessors<Params extends any[]> = {
  [Idx in keyof Params]: () => Params[Idx];
};

export function createComponentClassFactory<C extends new(...args: any[]) => HTMLElement>(
  componentClass: C,
  componentName: string,
): ComponentClass<C> {
  // this is what the DICC compiler would see - and thanks to the Accessors type,
  // it would be able to inject the correct values for '...args':
  return function(...args: Accessors<ConstructorParameters<C>>) {
    const injectedComponentClass =
      // @ts-expect-error: according to TS, this is a mixin, and mixin
      // constructors must have a single ...args: any[] argument..
      class
        extends componentClass {
      constructor() {
        super(...args.map((arg) => arg()) as ConstructorParameters<C>);
      }
    };

    window.customElements.define(componentName, injectedComponentClass);
    return injectedComponentClass;
  };
}

To get this to work with DICC, only two relatively minor things need to happen:

  • DICC needs to be able to inject tuples, as mentioned (that's probably easy enough)
  • just so you don't need to add satisfies ServiceDefinition<...> at the end of each createComponentClassFactory(...) call, DICC needs to try to extrapolate service definitions from any variable declaration which has a call signature, not just if the initializer of the variable is an arrow function / function expression, as it is now - that is also relatively simple and probably even the right thing to do anyway

I'm not entirely sure if injecting such component services into other services would work as expected because it's using generics, and those don't really play all that nice with DICC at the moment, but I think that if it was typed using the ComponentClass<C> interface then it just might..

@jahudka
Copy link
Collaborator

jahudka commented Nov 17, 2024

Hi, this is now implemented as of dicc-cli version 1.0.0-rc.1 and I'm pleased to say that it works as described above, so from my point of view this neatly solves compatibility between DICC and WebComponents without the need to write lots of userland code or to override DICC internal methods and / or data structures :-) try it out when you can and let me know how it works!

@jahudka
Copy link
Collaborator

jahudka commented Nov 22, 2024

I've played around with this on your code while debugging the excludePaths issue; this is what I found to work with the RC2 CLI:

1. Add this to src/components/component.ts or somewhere else you feel appropriate:

export interface AnyComponentFactory {
    (): HTMLElement;
}

export interface ComponentFactory<C extends HTMLElement> extends AnyComponentFactory {
    (): C;
}

2. Create src/components/definitions.ts with the following content:

import { ServiceDefinition } from 'dicc';
import { AnyComponentFactory, ComponentFactory } from './component';
import { RatingBox } from './rating-box';
import { TableRow } from './table-row';

class Entrypoint {
  constructor(
    readonly componentFactories: AnyComponentFactory[],
  ) {}
}

export const entrypoint = Entrypoint satisfies ServiceDefinition<Entrypoint>;

export namespace components {
  // this is how you register component services:
  export const ratingBox = createComponentFactory(RatingBox, 'rating-box');
  export const tableRow = createComponentFactory(TableRow, 'table-row');
}

type Accessors<Params extends any[]> = {
  [Idx in keyof Params]: () => Params[Idx];
};

function createComponentFactory<C extends new(...args: any[]) => HTMLElement>(
  componentClass: C,
  componentName: string,
) {
  return function(...args: Accessors<ConstructorParameters<C>>): ComponentFactory<InstanceType<C>> {
    const injectedComponentClass =
      // @ts-expect-error
      class
        extends componentClass {
        constructor() {
          super(...args.map((arg) => arg()) as ConstructorParameters<C>);
        }
      };

    window.customElements.define(componentName, injectedComponentClass);
    return () => new injectedComponentClass() as InstanceType<C>;
  };
}

3. Update your component code

In the RatingBox component's constructor, change the second argument from () => TableRow to ComponentFactory<TableRow> (ComponentFactory being one of the types defined in step 1).

4. Update dicc.yaml

Replace src/components/*.ts with src/components/definitions.ts in your resource list, since individual components now need to be registered using the createComponentFactory() call described above.

4. Prosper!

  • Somewhere at the beginning of your application lifecycle you should call container.get('entrypoint'), so that the Entrypoint service (and therefore its dependencies - the component factories) gets created. Otherwise document.createElement('my-component') and <my-component /> will not work.
  • When you code components, instead of injecting () => ComponentClass you'll need to use ComponentFactory<ComponentClass>. They're functionally the same thing; but since DICC injects by type identity rather than by type compatibility, it sees them as distinct types, so it wouldn't be able to autowire them correctly.
  • The AnyComponentFactory base interface is needed as a kind of marker so that you can inject all your component factories into the entrypoint service, because ComponentFactory<A> and ComponentFactory<B> and even ComponentFactory<any> are distinct types which DICC can't resolve (at least not yet, though it's something I'd like to add in the future).

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

No branches or pull requests

2 participants