Skip to content

Commit

Permalink
feat(core): added try... hooks and made init/destroy universal (#66)
Browse files Browse the repository at this point in the history
* feat(core): added try... hooks and made init/destroy universal

* refactor(core): move options from component's context to 3rd argument
  • Loading branch information
F0rsaken authored Oct 26, 2024
1 parent 7f140db commit 26716f5
Show file tree
Hide file tree
Showing 16 changed files with 313 additions and 79 deletions.
2 changes: 1 addition & 1 deletion docs/guide/built-in/use-component-context.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Use Component Context

A helper composable to get current components context.
A helper composable to get current components context. It's not fully typed, as it's cannot always assume a context it's used in.

```ts
import { useComponentContext } from 'ovee.js'
Expand Down
13 changes: 13 additions & 0 deletions docs/guide/built-in/use-component.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,16 @@ export function useChange() {
someComponent.cool()
}
```

If component accepts options, you can pass them in the composable.

```ts
import { useComponent } from 'ovee.js'
import { SomeComponent } from '@/components'

export function useChange() {
const someComponent = useComponent(SomeComponent, {
namespace: 'change'
})
}
```
73 changes: 61 additions & 12 deletions docs/guide/usage/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,16 @@ Now we can get to the *crème de la crème* of Ovee: __components__! The reusabl

## Defining a component

Similarly to [Modules](./modules.md), we start with a `defineComponent` method, which accepts a __setup function__, inside which you can access the current component's context.
Similarly to [Modules](./modules.md), we start with a `defineComponent` method, which accepts a __setup function__, inside which you can access the current component's context and options.

```ts
import { defineComponent } from 'ovee.js'

export const MyComponent = defineComponent((element, {
app, on, off, emit, name, options
}) => {
console.log('hi from component!')
})
export const MyComponent = defineComponent(
(element, { app, on, off, emit, name }, options) => {
console.log('hi from component!')
}
)
```

And just register it in the app
Expand All @@ -25,9 +25,10 @@ createApp()
.component(MyComponent)
```

The setup function is called with two arguments:
The setup function is called with three arguments:
- HTML Element on which the component is connected to
- component's context, which will be explained later on
- component's options that were passed during component registration

::: tip
Unlike modules, the component's setup function is called when the component's instance is created, so when a proper HTML tag or data attribute is used, not when it's registered in the app.
Expand Down Expand Up @@ -88,6 +89,17 @@ export const MyComponent = defineComponent(() => {
})
```

::: tip
If you're not sure if code is executed inside a component, f.ex. when writing really versatile composable, you can use hooks with `try...` prefix. If code is executed outside of a component context, then nothing will happen and no errors will be logged.

```ts
export function useComposable() {
tryOnMounted(() => { /* ... */ }) // [!code focus]
tryOnUnmounted(() => { /* ... */ }) // [!code focus]
}
```
:::

## Component context

It's time to address a second setup parameter: __component context__. It's an object, containing a few important things:
Expand All @@ -97,11 +109,10 @@ It's time to address a second setup parameter: __component context__. It's an ob
- `off` - function for removing event handlers
- `emit` - function for emitting events
- `name` - component's name
- `options` - configuration object used, when registering component

Functions associated with event handling will be discussed in [the next chapter](./event-handling.md). `app` instance is rarely needed in components, so we won't talk about it here, but you can find a reference to it in the API section.

We also have `options`, which similarly to `modules`, are a simple way to globally pass configuration object for all instances of a specific component. To set component options, all you need to do is add them when registering to the app:
We also have 3rd argument: `options`, which similarly to `modules`, are a simple way to globally pass configuration object for all instances of a specific component. To set component options, all you need to do is add them when registering to the app:

::: code-group
```ts [app.ts]
Expand All @@ -114,7 +125,7 @@ createApp()
```

```ts [MyComponent.ts]
export const MyComponent = defineComponent((element, { options }) => {
export const MyComponent = defineComponent((element, _, options) => {
const event = options.event

// ...
Expand All @@ -125,9 +136,9 @@ export const MyComponent = defineComponent((element, { options }) => {
Providing default options is just a plain JS

```ts
export const MyComponent = defineComponent((element, { // [!code focus]
export const MyComponent = defineComponent((element, _, // [!code focus]
options = { event: 'change' } // [!code focus]
}) => { // [!code focus]
) => { // [!code focus]
const event = options.event

// ...
Expand Down Expand Up @@ -194,3 +205,41 @@ export const MyComponent = defineComponent(async () => {
const data = await fetch(`https://api.fallback.com/awesome/data`)
})
```

## Typing component with TypeScript

You can fully type your component, by passing all generics, but it's not recommended. Most of the time, you want to specify only specific parts of the component, like only `options` or only root `element`. The best way to do that is inside function itself, like so:

```ts
interface MyComponentOptions {
event?: string
}

interface MyComponentReturn {
refresh(): void
}

export const MyComponent = defineComponent(
(el: HTMLAnchorElement, {}, options: MyComponentOptions = { event: 'change' }): MyComponentReturn => {
return {
refresh: () => {
// ...
}
}
}
)
```

This way you can type only one specific parameter, and the rest will be infered by TS with usage. For example, to just type element, and let the return type be inferred:

```ts
export const MyComponent = defineComponent(
(el: HTMLAnchorElement, { on, off }) => {
return {
refresh: (now?: boolean) => {
// ...
}
}
}
)
```
20 changes: 20 additions & 0 deletions docs/guide/usage/composables.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,23 @@ export const MyComponent = defineComponent(() => {
otherInstance.doSomething() // [!code focus]
})
```

## Universal hooks

If we want to write a composable that could be called inside either a module or a component, and we want to react to the lifecycle of out context, then we would need to use both `onMounted/onUnmounted` and `onInit/onDestroy`.

Fortunately, `init/destroy` hooks are universal and work inside both modules and components. So we can just write:

```ts
export function useMyComposable() {
onInit(() => {
// run during `init` of a module
// or during `mounted` of a component
})

onDestroy(() => {
// run during `destroy` of a module
// or during `unmounted` of a component
})
}
```
112 changes: 112 additions & 0 deletions docs/guide/usage/jsx.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,118 @@ export const MyComponent = defineComponent(() => {
})
```

## Props

JSX components can accept attributes, that will be rendered in HTML, but what if you want to pass some data from parent component to child, like f.ex. pure JS object or function. That's where component props comes in.

You can specify component props as the first agument of `defineComponent`. It's an object, which key is the name of the prop and a value is prop's `type` or options object, with prop's `type`, `default` value or `required` flag. Every prop is optional if not marked as required.

```tsx
import { Prop } from 'ovee.js'

export const MyComponent = defineComponent({
// optional string prop
name: Prop.string,
// reqruied string prop
amount: {
type: Prop.number,
required: true,
},
// optional string or number prop, which is nullable
id: [Prop.string, Prop.number, null],
// optional data object with default value
data: {
type: Prop.object,
default: () => ({ id: 0, value: null })
}
}, (el, { props }) => {
// ...
})

// usage
export const ParentComponent = defineComponent(() => {
const data = {
id: 10,
value: 'Shaekspear\'s Hamlet'
}

useTemplate(() => (
<div>
<MyComponent.jsx name='parent' amount={10} id={null} data={data} />
</div>
))
})
```

Specifying prop's type no only is a good way to document it, it also allow for both runtime type check in browser and TypeScript type check during build/in editor. All prop types are available under imported object `Prop`. Available types are:

- `boolean`
- `string`
- `symbol`
- `number`
- `array`
- `object`
- `function`
- `any` - similar to TS primitive `any`, it accepts any possbile value
- `null` - to specify `null`, just use pure `null` value

To specify more than one type for a prop, pass them as an array.

```ts
export const MyComponent = defineComponent({
id: [Prop.string, Prop.number, null], // [!code focus]
}, (el, { props }) => {
// ...
})
```

To mark prop as required, pass an object with `required: true`.

```ts
export const MyComponent = defineComponent({
id: { // [!code focus]
type: Prop.string, // [!code focus]
required: true // [!code focus]
}, // [!code focus]
}, (el, { props }) => {
// ...
})
```

You can speficy a default value for a prop. It can be a plain value or a factory function. Factory function is required, when providing with default array or object.

```ts
export const MyComponent = defineComponent({
type: { // [!code focus]
type: Prop.string, // [!code focus]
default: 'normal' // [!code focus]
}, // [!code focus]
values: { // [!code focus]
type: Prop.array, // [!code focus]
default: () => [] // [!code focus]
} // [!code focus]
}, (el, { props }) => {
// ...
})
```

::: warning
Do not use `key` or `children` as a prop names, as they are used internally by `ovee`
:::

### More narrow types

Every prop type accepts a generic argument which can make types more narrow, but it is only a TS type check, not a runtime check. Passed generic needs to match used type, so if you want to type an array of values, you should use `Prop.array`, but if you want to type an object, you should use `Prop.object`.

Some examples of narrowing types:

```ts
Prop.string<'primary' | ''> // string union
Prop.array<number[]> // array of numbers
Prop.object<User> // accepts user object
Prop.any<string | string[]> // if you don't care about runtime check, you can use any and pass all possible types
```

## Slots

Let's imagine a situation where we have a button component. Through attributes, we can pass the button's inner text. That's cool. But what when we want to pass some more specific HTML? Maybe a bold or italic in certain parts of the text? Maybe something like an icon? Then we have a problem. We could make multiple components, that reuse this one, but it's not really efficient and heavily violates the DRY rule.
Expand Down
11 changes: 11 additions & 0 deletions docs/guide/usage/modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,17 @@ Tho to be fair, situation when module is being destroyed before app itself and u
Both of these lifecycle functions run only once for each module instance
:::

::: tip
If you're not sure if code is executed inside a module, f.ex. when writing really versatile composable, you can use hooks with `try...` prefix. If code is executed outside of a module context, then nothing will happen and no errors will be logged.

```ts
export function useComposable() {
tryOnInit(() => { /* ... */ }) // [!code focus]
tryOnDestroy(() => { /* ... */ }) // [!code focus]
}
```
:::

## Module instance

When declaring a module, you can optionally return an object. If you do that, it will be saved as a module's instance and exposed for rest of the app. This mechanism allows you to return a methods or values, for module control and data sharing.
Expand Down
19 changes: 13 additions & 6 deletions packages/ovee/src/composables/hooks/onDestroy.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
import { injectModuleContext } from '@/core';
import { injectComponentContext, injectModuleContext } from '@/core';
import { Logger } from '@/errors';
import { getNoContextWarning } from '@/utils';

const logger = new Logger('onDestroy');

export function onDestroy(cb: () => void) {
const instance = injectModuleContext(true);
export function onDestroy(cb: () => void, silent = false) {
const moduleInstance = injectModuleContext(true);
const componentInstance = injectComponentContext(true);

if (!instance) {
logger.warn(getNoContextWarning('onDestroy'));
if (!moduleInstance && !componentInstance) {
if (!silent) logger.warn(getNoContextWarning('onDestroy'));

return;
}

instance.destroyBus.on(cb);
const bus = moduleInstance?.destroyBus || componentInstance?.unmountBus;

bus?.on(cb);
}

export function tryOnDestroy(cb: () => void) {
onDestroy(cb, true);
}
19 changes: 13 additions & 6 deletions packages/ovee/src/composables/hooks/onInit.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
import { injectModuleContext } from '@/core';
import { injectComponentContext, injectModuleContext } from '@/core';
import { Logger } from '@/errors';
import { getNoContextWarning } from '@/utils';

const logger = new Logger('onInit');

export function onInit(cb: () => void) {
const instance = injectModuleContext(true);
export function onInit(cb: () => void, silent = false) {
const moduleInstance = injectModuleContext(true);
const componentInstance = injectComponentContext(true);

if (!instance) {
logger.warn(getNoContextWarning('onInit'));
if (!moduleInstance && !componentInstance) {
if (!silent) logger.warn(getNoContextWarning('onInit'));

return;
}

instance.initBus.on(cb);
const bus = moduleInstance?.initBus || componentInstance?.mountBus;

bus?.on(cb);
}

export function tryOnInit(cb: () => void) {
onInit(cb, true);
}
8 changes: 6 additions & 2 deletions packages/ovee/src/composables/hooks/onMounted.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,18 @@ import { getNoContextWarning } from '@/utils';

const logger = new Logger('onMounted');

export function onMounted(cb: () => void) {
export function onMounted(cb: () => void, silent = false) {
const instance = injectComponentContext(true);

if (!instance) {
logger.warn(getNoContextWarning('onMounted'));
if (!silent) logger.warn(getNoContextWarning('onMounted'));

return;
}

instance.mountBus.on(cb);
}

export function tryOnMounted(cb: () => void) {
onMounted(cb, true);
}
Loading

0 comments on commit 26716f5

Please sign in to comment.