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

Proposal: presence #414

Merged

Conversation

EthanStandel
Copy link
Contributor

@EthanStandel EthanStandel commented Apr 22, 2023

This is a proposal to merge my solid-presence-signal package from it's own repo into the @solid-primitives ecosystem. This package is a conversion of a lightweight React animation package called use-presence that I've become partial to (and contributed to) for the high-control it provides developers by simply exposing the animation state as state-primitives.

@changeset-bot
Copy link

changeset-bot bot commented Apr 22, 2023

⚠️ No Changeset found

Latest commit: bc3efb7

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

Copy link
Member

@thetarnav thetarnav left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for bringing this package to solid-primitives 🥳
I love the concept behind these primitives and I'm interested to see how the patterns around animations and transitions will evolve over time in solid.

package-lock.json Outdated Show resolved Hide resolved
packages/presence-signal/README.md Outdated Show resolved Hide resolved
packages/presence-signal/package.json Outdated Show resolved Hide resolved
packages/presence-signal/src/createPresenceSignal.ts Outdated Show resolved Hide resolved
packages/presence-signal/src/createPresenceSignal.ts Outdated Show resolved Hide resolved
packages/presence-signal/src/createPresenceSignal.ts Outdated Show resolved Hide resolved
packages/presence-signal/src/createPresenceSignal.ts Outdated Show resolved Hide resolved
packages/presence-signal/src/createPresenceSwitchSignal.ts Outdated Show resolved Hide resolved
packages/presence-signal/README.md Outdated Show resolved Hide resolved
packages/presence-signal/src/createPresenceSignal.ts Outdated Show resolved Hide resolved
@EthanStandel EthanStandel marked this pull request as ready for review April 24, 2023 05:05
@EthanStandel
Copy link
Contributor Author

EthanStandel commented Apr 24, 2023

Thank you very much @thetarnav for the detailed review! I've responded to all comments and for the sake of quick-scan reading all of my resonses contain either 👍, ❓, or 🛑 emojis.

👍 means that I agree with the feedback and have updated accordingly
❓ means I have a follow up question about that feedback
🛑 means that I either disagree with the feedback or was unable to fulfill the request

Client & server tests have be added for createPresence & createPresenceSwitch, and so I have opened this up for review officially as a package proposal!

One leftover question I do have is what the situation is with adding a CHANGELOG.md for new packages. It appears that running pnpm new-package presence didn't make one for me. Is this file created in an automated process or should I add it manually ❓

@EthanStandel EthanStandel changed the title Proposal: presence-signal Proposal: presence Apr 24, 2023
@snnsnn
Copy link

snnsnn commented Apr 24, 2023

I believe implementation could be simplified significantly like so:

import {
  Accessor,
  createComputed,
  createSignal,
  on,
  untrack
} from 'solid-js';

interface Options {
  initialTransition?: boolean;
  duration: number | { enter: number; exit: number };
}

interface State {
  get isMounted(): boolean;
  get entering(): boolean;
  get entered(): boolean;
  get exiting(): boolean;
  get exited(): boolean;
}

type Status = 'entering' | 'entered' | 'exiting' | 'exited';

export function usePresence(show: Accessor<boolean>, options: Options): State {
  const { initialTransition, duration } = options;
  const enter = typeof duration === 'number' ? duration : duration.enter;
  const exit = typeof duration === 'number' ? duration : duration.exit;
  const initialVal = untrack(show) ? 'entered' : 'exited';
  const [state, setState] = createSignal<Status>(initialVal);

  const handleTransition = () => {
    const val = show();
    setState(val ? 'entering' : 'exiting');
    setTimeout(() => setState(val ? 'entered' : 'exited'), val ? enter : exit);
  };

  if (initialTransition) {
    handleTransition();
  }

  createComputed(on(show, handleTransition, { defer: true }));

  const getState: State = {
    get isMounted() {
      return state() !== 'exited';
    },
    get entering() {
      return state() === 'entering';
    },
    get entered() {
      return state() === 'entered';
    },
    get exiting() {
      return state() === 'exiting';
    },
    get exited() {
      return state() === 'exited';
    },
  };

  return getState;
}

Here state updates triggers single update, so we can avoid batching.

The naming convention the react implementation uses a bit confusing, I think clear and straightforward names are better.

You can see a working demo: https://stackblitz.com/edit/solidjs-templates-xbyq1m?file=src/use-presence.ts

@atk
Copy link
Member

atk commented Apr 24, 2023

Here the getters subscribe to any reactive change of state(), which is significantly less performant than using createSelector(). This is a low hanging fruit for optimization.

@snnsnn
Copy link

snnsnn commented Apr 24, 2023

Here the getters subscribe to any reactive change of state(), which is significantly less performant than using createSelector(). This is a low hanging fruit for optimization.

Exactly. We can also encode status as union of numbers or single characters to shave off some bits.

I wanted to see the initial reaction. I will open a PR if you or other maintainers say OK.

@thetarnav
Copy link
Member

thetarnav commented Apr 24, 2023

I like the @snnsnn idea very much. We should aim to do as little work as possible inside these primitives, whether something should be kept in a separate signal to not over-execute is the users' decision. If we can maintain the state using a single signal, either by using an enum like @snnsnn suggested, or a plain object that is updated by restructuring.

We have utilities like createDerivedStaticStore or destructure to help memoize individual properties if needed.

As for the shape of the returned object, it either should be an object with getters, just like a store/props. Or a single signal (memo) returning the derived object state in an immutable way.

with getters

declare function createPresence(options): {
  readonly isMounted: boolean;
  readonly entering: boolean;
  readonly entered: boolean;
  readonly exiting: boolean;
  readonly exited: boolean;
}

const presense = createPresense(/* ... */)

createEffect(() => {
  console.log(presense.entering)
})

// manually memoize if needed
const entering = createMemo(() => presense.entering)

a single signal

declare function createPresence(options): Accessor<{
  readonly isMounted: boolean;
  readonly entering: boolean;
  readonly entered: boolean;
  readonly exiting: boolean;
  readonly exited: boolean;
}>

const presense = createPresense(/* ... */)

createEffect(() => {
  console.log(presense().entering)
})

// manually memoize if needed
const entering = createMemo(() => presense().entering)

@atk I think it's up to the user if he needs to optimize or not. It's better to return the enum signal directly from the primitive and let the user decide how to structure it further instead of optimizing prematurely with selectors for each property.

But what is more important than optimizations is meeting user expectations.
If I access state.property, I'm assuming that property access is an individual signal, that won't get triggered on unrelated changes.

@atk
Copy link
Member

atk commented Apr 24, 2023

Creating objects is definitely slower than just using a string out of a union.

@snnsnn
Copy link

snnsnn commented Apr 24, 2023

I like the idea of exposing the state directly:

import { Accessor, createComputed, createSignal, on, untrack } from 'solid-js';

interface Options {
  initialTransition?: boolean;
  duration: number | { enter: number; exit: number };
}

type State = 'entering' | 'entered' | 'exiting' | 'exited';

export function usePresence(
  show: Accessor<boolean>,
  options: Options,
): [Accessor<boolean>, Accessor<State>] {
  const { initialTransition, duration } = options;
  const enter = typeof duration === 'number' ? duration : duration.enter;
  const exit = typeof duration === 'number' ? duration : duration.exit;
  const initialVal = untrack(show) ? 'entered' : 'exited';
  const [state, setState] = createSignal<State>(initialVal);

  const handleTransition = () => {
    const val = show();
    setState(val ? 'entering' : 'exiting');
    setTimeout(() => setState(val ? 'entered' : 'exited'), val ? enter : exit);
  };

  if (initialTransition) {
    handleTransition();
  }

  createComputed(on(show, handleTransition, { defer: true }));

  const isMounted = () => state() !== 'exited';

  return [isMounted, state];
}

Now we can take advantage of array destructuring for easier re-naming:

const [isMounted, state] = usePresence(show, {
  duration: 1000,
  initialTransition: true,
});

I like the idea of returning an array but I am not sure if it is best to return an object or an array. What do you think?

And about the memoization, we don't need memoization because transitioning from false to false, i.e state() === 'entering' , has no effect on the UI:

<Show when={isMounted()}>
  <div
    style={{
      transition: 'all .5s linear',
    
     // Transitioning from false to false has no effect
      ...(state() === 'entering' && {
        color: 'green',
      }),

      ...(state() === 'entered' && {
        color: 'red',
      }),
      ...(state() === 'exiting' && {
        color: 'orange',
      }),
      ...(state() === 'exiting' && {
        color: 'blue',
      }),
    }}
  >
    Some Content!!
  </div>
</Show>

Even if we use memo, we will run this computation inside the memo function, so in terms of net computation work there is no difference. I believe it is best to run the computation when it is needed rather than beforehand.

@atk
Copy link
Member

atk commented Apr 24, 2023

You could even use it in a branchless version

<Show when={isMounted()}>
  <div
    style={{
      transition: 'all .5s linear',
      color: ({
        entering: 'green',
        entered: 'red',
        exiting: 'orange',
        exited: 'blue'
      })[state()]
    }}
  >Some Content!!</div>
</Show>

I'd probably prefer a CSS variable, because that helps the browser optimize the color changes, but this should work, too.

@thetarnav
Copy link
Member

Now we can take advantage of array destructuring for easier re-naming

Array destructuring is usually used to separate reads from writes, state from actions, I don't see a point in returning isMounted in this form when it could be written by the user.
It's just a pattern to be added to the docs

const state = createPresense(/* ... */)

const isMounted = createMemo(() => state() !== 'exited')

const isSelectedState = createSelector(state)

createEffect(() => {
  if (isSelectedState('entering')) { /* ... */ }
})

@snnsnn
Copy link

snnsnn commented Apr 24, 2023

I don't see a point in returning isMounted

Good point. I don't like redundancies either.

One last question, about the naming, presense is a facy name but does not convey the true nature of what we are doing here and is plainly confusing if you are not familiar with it. Can we come up with a better name?

@thetarnav
Copy link
Member

I'm only familiar with the naming from the work on motionone, and here it's used to describe a similar pattern, so I quite like it.

@snnsnn
Copy link

snnsnn commented Apr 24, 2023

I'm only familiar with the naming from the work on motionone, and here it's used to describe a similar pattern, so I quite like it.

Name is good, memorable and distinct, my concern is its connotations, which is also stressed further by the proposed description, "A small SolidJS utility to animate the presence of an element" gives you the idea of interacting with the underlying DOM element, all we do is delay the unmounting of the element through a derived signal.

Maybe it is the function name we need to find an alternative. How about extendPresence instead of usePrensence? This clearly indicates an element's presence is extended for a specified duration.

@thetarnav
Copy link
Member

thetarnav commented Apr 24, 2023

I'm looking at it as createPresence as an implied shortcut for createPresenceState. A similar naming to the rest of the packages in this lib.

A small SolidJS utility to animate the presence of an element

The usage with DOM elements should be only brought up as an example of one usecase. There are probably other places in the documentation to make the boundaries clear.

@snnsnn
Copy link

snnsnn commented Apr 24, 2023

I'm looking at it as createPresence as an implied shortcut for createPresenceState.

Yeah, this may make more sense since we are aiming to apply animations both when mounting and unmounting, while the name extendPresence applies only to the latter one.

OK, thanks. I will send a PR.

@EthanStandel
Copy link
Contributor Author

EthanStandel commented Apr 24, 2023

Apologies for not being in on this conversation all day. I've been trying to keep track of it, but I have an upcoming deadlines for official work stuff which generally makes me unavailable for OSS stuff during 9-5EST, but I wanted to make sure I got in on this discussion before a PR was opened against my fork.

I want to say that I have no problem with changing the API itself. The value of this package, to me, is to have an exposed state-primitive (or set of state primitives) that can represent the presence of elements/components/data. However, exactly how it is exposed to the end user is totally up for debate in this PR.

@snnsnn @thetarnav I do like the idea of turning this effectively into a strict state machine, but I also have a few concerns with how existing paradigms that are useful in solid-presence-signal/use-presence would be maintained. I like @snnsnn's StackBlitz example using text-color but I also think that the discussion has focused a bit to much on this as if it's a use-case whereas I think that is more of a good debugging tool. I think a more realistic use-case for this is one that I have laid out in the StackBlitz that I added to the README, where you have an element animating in stage-left and out stage-right. That only works because of an existing intermediate state where isEntering is true but isVisible is false which allows the element to first render in it's pre-entrance state with opacity: 0; transform: translateX(-25px);, at which point in the current implementation, we requestAnimationFrame and on that next frame we declare isVisible to be true which is the state that determines that we should actually be animating towards visibility. It doesn't appear that your state-machine model would ever render that intermediate state, thus making this kind of entrance impossible. I do have a few other concerns about the code in your first comment, but this is the most pressing issue.

While we're already considering changes to the API, I figure this one might be worth bringing to the table as well. I originally contributed the usePresenceSwitch hook to the use-presence package, and my original intention with that hook was for it to entirely replace usePresence because usePresenceSwitch is entirely capable of accepting a boolean as it's controlling state and handling the rest of the existing model the same. The owner of that repo preferred that it be written as a separate hook to keep the use-cases separate, so I went in that direction with the PR. When I forked the repo to make the original Solid based implementation, I intended to just keep the exact patterns and API. However, now that it's a larger community discussion I'd like to bring that topic back into frame: should createPresence & createPresenceSwitch just be merged and become a single export ❓

@thetarnav
Copy link
Member

thetarnav commented Apr 24, 2023

should createPresence & createPresenceSwitch just be merged and become a single export ❓

Honestly, it was one of my initial impressions when looking at the implementation as well. "Can't I just pass a boolean?" So I'm glad you're bringing this up, and I'm down to go in this direction.
The only concerning part is extending the features of the primitive that may not be useful for everyone. But I think it's worth it considering that we get a single, unified interface to work with depending on our needs. Much more primitive-like™

And about the other proposed changes: the same things that were possible with the previous API, should be possible with this one too. When making it into a pure state-machine, how do we let the user force animations to happen, which is done by forcing a reflow internally now.
The idea of returning just a single enum signal is nice, but I'm not sure if this state can be expressed like this. Are the different "phases" really exclusive? Maybe is can be achieved with the help of some additional helpers as we did for isMounted. Not sure.

I think we need to establish what the primitive is supposed to do exactly, test it, and publish it as the initial implementation. Then we can switch the implementation and even the API details all we want, as long as the same things are still possible.
As the tests are in place, I think we are in a good spot? I guess that unit testing triggering reflows would be hard, so at least the demo should contain that functionality.

@snnsnn
Copy link

snnsnn commented Apr 24, 2023

you have an element animating in stage-left and out stage-right.

@EthanStandel Sorry but I could not understand your concern. Is it synronizing enter and exit transitions for multiple elements or executing in-left/out-right movement? State machine offers far greater flexibility than manually swapping states. You can derive any state from existing states like we did for isMounted.

https://stackblitz.com/edit/solidjs-templates-2kjppq?file=src/App.tsx

In your implementation you have too many moving pieces and too many manual updates which will degrade performance considerably. Solid is fast but still lags if you have too many components. Also, you don't batch update, which triggers undesired intermediate state updates.

@snnsnn
Copy link

snnsnn commented Apr 24, 2023

I think we need to establish what the primitive is supposed to do exactly, test it, and publish it as the initial implementation

Here is my understanding, which was written for the README file:

A small utility to have stronger control over an element's presense in the DOM in order to apply enter and exit transitions.

When an elements visibilty tied to a signal, the elements gets mounted and unmounted abruptly, not permitting to apply any css transitions.

<Show when={show()}>
  <div></div>
</Show>

Through createPresence we create a derived signal, through which we transition from false to true in two steps, entering and entered, and from true to false in two steps exiting and exited.

const [show, setShow] = createSignal(true);

const state = usePresence(show, {
  duration: 500,
  initialTransition: true,
});

createEffect(() => console.log(state()));

This allows us to apply enter and exit transitions to an element but first we need to hand over the control of the element's visibilty to the state:

const isMounted = () => state() !== 'exited';
<Show when={isMounted()}>
  <div></div>
</Show>

Now, when show is set to false, div will not disappear immediately but wait for the exiting state to complete and move over to exited. In other words, we extended element's presence in the DOM for the duration of exiting state.

When show is set to true, state will move into entering and remain there for 500ms then automaticall move to entered indefinitely. This will allow us to use show one style during entering and another one in entered state:

<div
  style={{
    transition: 'all .5s linear',
    
    ...(state() === 'entering' && {
      color: 'green',
    }),
    
    ...(state() === 'entered' && {
      color: 'red',
    }),
  }}
>
  Some Content!!
</div>

Setting show to false will move the state value exiting for 500ms and then exited.

<div
  style={{
    transition: 'all .5s linear',
  
    ...(state() === 'exiting' && {
      color: 'orange',
    }),
    
    ...(state() === 'exited' && {
      color: 'blue',
    }),
  }}
>
  Some Content!!
</div>

@EthanStandel
Copy link
Contributor Author

EthanStandel commented Apr 24, 2023

@thetarnav I'd be happy to just combine them! When this was a React hook there was more risk of lost frames from triggering unnecessary state updates from the extra effect, but in Solid the user has more control over what a state update does and the state updates themselves are generally far less weighty. So I don't believe this actually is a relevant issue here.

@snnsnn Your understanding of the model is definitely correct, but the new StackBlitz you shared isn't doing what you think it is. If you expand the transition time to be something longer, it's a little bit easier to see the problem.

When you trigger the presence of an element to enter, it's immediately rendering in a hidden state and then waiting ${enterDuration}ms in that hidden state, and then only once you have it in the "entered" state does it actually begin transitioning in. So in your model, all entrance transitions actually take twice as long as they're supposed to. Here's a fork with some small changes to make the problem a bit easier to see.

I think that if you just added one more state (I think it's just one) to the flow, it could cover for this. Here's a janky fork of the previous example with a potential fix. I got away with just awaiting setTimeout of 0 in this case rather than waiting for a whole animation frame like the original implementation, but I don't know if there are risks to this approach. It seems to work fine and would be overall more performant because the transition times wouldn't be lagged by a frame. The waiting out a full frame could have just been a React specific requirement.

@snnsnn
Copy link

snnsnn commented Apr 24, 2023

When you trigger the presence of an element to enter, it's immediately rendering in a hidden state and then waiting ${enterDuration}ms in that hidden state, and then only once you have it in the "entered" state does it actually begin transitioning in. So in your model, all entrance transitions actually take twice as long as they're supposed to.

I see, timing and triggering transitions was different in my initial implementation but that is lost somewhere between the iterations. I will fix it. Thank you.

@snnsnn
Copy link

snnsnn commented Apr 25, 2023

Ok, I had time to look at it with a rested mind. The problem is, as you said, the element is mounted with the initial state and remain there until entered states kicks in, only then the css transitions applied, then we see the results.

In my earlier initial implementation (not shown in the discussion) did not suffer from this, because the element was mounted with entering style and the entered styles was flushed immediately, rather than waiting for the duration of the enter value like below. In that implementation, I was relying on css for transitioning. That is why I did not notice the bug. My salutation to you for catching it so quickly:

setState('entering');
setTimeout(() => setState('entered'), 0);

About the solution, it is simple, we add an initial state. Initial state should be there from the beginning because an element can be in one of these tree resting states, initial, entered, exited and two transition states, entering and exiting. Not having the initial state was a bug because we can not have two transition states between two resting states.

Here is a fixed demo:

https://stackblitz.com/edit/solidjs-templates-d6rfxr?file=src/use-presence.ts

Now having the initial state, element will be mounted with transform: 'translateX(-25px)', then will transition to transform: 'translateX(0)', that is our left-in transition :

<div
  style={{
    background: 'gray',
    transition: 'all 3s linear',

    ...(state() === 'initial' && {
      "background-color": 'red',
      opacity: '1',
      transform: 'translateX(-25px)',
    }),
    
    ...(state() === 'entering' && {
      opacity: '1',
      "background-color": 'green',
      transform: 'translateX(0)',
    }),

    ...(state() === 'exiting' && {
      opacity: '1',
      "background-color": 'blue',
      transform: 'translateX(25px)',
    }),
  }}
>
  Some Content!!
</div>

When show is toggled off, state will move to exiting, moving the transform value to transform: 'translateX(25px)', and that is our right-out.

Without the initial state, we can not mount the element with the property of transform: 'translateX(-25px)'. If we have it as an unconditional value like below, as soon we exit the transition state, element will move back to -25px:

<div
  style={{
    background: 'gray',
    transition: 'all 3s linear',
   transform: 'translateX(-25px)',

Your implementation also have similar issues. When we use the values from SecondExample in FirstExample, element does not have left-in right-out transition anymore. I am not sure if that is intentional, it is something I discovered while trying to understand your implementation.

https://stackblitz.com/edit/solidjs-templates-jpwjcz?file=src%2FApp.tsx

@atk
Copy link
Member

atk commented Apr 26, 2023

Since "initial" is basically an unmanaged state, it could also be an empty string, which would allow using the state as conditional, e.g. if (state()) ....

@EthanStandel
Copy link
Contributor Author

EthanStandel commented Apr 26, 2023

@snnsnn I like this! I think this fulfills everything that I like about this model and with better performance! I would request that just for the sake of caution, we still have the reflow logic after setState(visible ? 'initial' : 'exiting');. And then also createPresence is going to be merged with createPresenceSwitch as well and they will be a single export, which I'm sure will open up the small bit of logic left there to a bit more scrutiny. I intend to update this soon but this is a big deadline week in my dayjob which has been taking all my time (it's currently 2am for me and I'm just getting to my OSS stuff).

If we agree that this is the ideal model going forward, do you want to open up a PR to my fork, or should we focus more on merging what I have first and then you can open up a PR with a major version change to update & solidify the API? I just want to make sure that you're credited for your work 😄

@atk I personally prefer that it be legible and clear with exactly what that state is because it is distinctly its own state. I'm not 100% sure about "initial" but I think that state name is more clear than anything else I can think of 😅

@snnsnn
Copy link

snnsnn commented Apr 26, 2023

I would request that just for the sake of caution, we still have the reflow logic after setState(visible ? 'initial' : 'exiting');

Do you mean if we flush our component with a transition state immediately after mounting? React's version mounts elements then sets to updating css properties in another render cycle, but we mount element with initial proprerties, then we flush our component with transition so to have the same effect.

Should we focus more on merging what I have first and then you can open up a PR with a major version change to update & solidify the API?

That is OK for me if maintainers OK with that.

@EthanStandel
Copy link
Contributor Author

@snnsnn I don't mean anything from a state/Solid perspective. I mean it specifically from a DOM perspective to ensure that the "initial" state truly gets painted because if it doesn't then the incoming transition will fail. That's why the initial use-presence library has this line. Here's a SO thread on the topic.

I waffled on getting rid of the reflow when I first converted use-presence to Solid because we're waiting out a full animation frame in the interstitial state, so it should reliably update. But in your version we're only waiting out a single event-loop which will give the browser far less time to paint so I think having the reflow logic will be even more critical in your model.

@snnsnn
Copy link

snnsnn commented Apr 26, 2023

mean it specifically from a DOM perspective to ensure that the "initial" state truly gets painted

Element will be mounted with initial properties if placed under Show but I will look into cases where Show is not used and the element kept being mounted but becomes hidden and visible between updates.

@snnsnn
Copy link

snnsnn commented Apr 26, 2023

@atk @thetarnav @EthanStandel I am somewhat satisfied with the result but still iffy about the passing enter and exit transition durations to createPresence because in my mind createPresence should deal with extending an element's lifetime for a specified duration and that is all it should do. The timing of the transitions and other css related stuff should be left to the css because there is no way to enforce transition durations via createPresence, we have to use css properties for transitions anyway.

You may have notice I had a similar comment deleted but here writing it again.

If we use createPresence for extending element's lifetime only, we can mount the element in entering state and flush entered state immediately using setTimeout 0:

setState('entering');
setTimeout(() => setState('entered'), 0);

Then leave everything to css, since css transition will take effect and animate towards the desired state (entered).

If element does not have animation set, there won't be any inconsistencies because element is actually entered to the DOM. The point I am trying to make is, element's css will never be in an inconsistent state.

This way, the state reflects the actual status of the element, and we won't loose any functionality compared to the implementation we agreed upon, the one with the initial state.

On the contrary, our implementation will have one less setTimout and our api will be cleaner and more straightforward. It will be easier to manage multiple elements (createPresenceSwitch) due to having less complexity. Plus, we will get rid of the confusion and discrepancy (because we have to manually adjust values both in css and js sides and keep them in sync) brought by the transition values we pass to createPresence .

type State = 'entering' | 'entered' | 'exiting' | 'exited';

export function usePresence(
  show: Accessor<boolean>,
  options: Options
): Accessor<State> {
  const { initialTransition, duration } = options;
  const initialVal = untrack(show) ? 'entered' : 'exited';
  const [state, setState] = createSignal<State>(initialVal);

  let timeout: number;
  const handleTransition = () => {
    const val = show();
    clearTimeout(timeout);

    setState(val ? 'entering' : 'exiting');
    timeout = setTimeout(() => setState(val ? 'entered' : 'exited'), val ? 0 : duration);
  };
 
  // Animate element only when `show` set to true
  if (initialTransition && initialVal === 'entered') {
    handleTransition();
  }

  createComputed(on(show, handleTransition, { defer: true }));
  return state;
}

Here is a working demo: https://stackblitz.com/edit/solidjs-templates-nrye3h?file=src%2Fuse-presence.ts

This version closely resembles what https://motion.dev/solid/presence has only they have different naming, initial for entering, animate for entered and exit for exiting.

However this version will be less flexible in terms of what we can do with the states. With previous implementation we can initialize elements with temporary styles and swap styles during transitions. It can be used for purposes other than css transitions like applying classes to run animations or showing and hiding elements rather than mounting and unmounting them. Simply there is more room for maneuvering.

Maybe we can provide both versions and let the user decide.

@thetarnav
Copy link
Member

thetarnav commented Apr 26, 2023

I like the API of transition-group primitives where the transition is ended by calling the done function.
https://github.com/solidjs-community/solid-primitives/tree/main/packages/transition-group#createswitchtransition
So you can use a timeout, wait for transitionend event, or call it immediately if you want to skip the transition compleately.
Then solid-transition-group has a flexibility to choose when the transition should be ended:
https://github.com/solidjs-community/solid-transition-group/blob/main/src/common.ts#L60

@snnsnn
Copy link

snnsnn commented Apr 26, 2023

I like the API of transition-group primitives where the transition is ended by calling the done function.

It looks very complex. I would prefer something similar to current implementation. Take a look at this:

<div
  classList={{
    'animate-in': state() === 'entering',
    'animate-out': state() === 'exiting',
  }}
>
  Some Content!!
</div>

I would keep a simple internal state and and expose a handle to drive the progress or mark it done and let the reactivity do its work rather than setting listeners.

@thetarnav
Copy link
Member

@EthanStandel do you want to add any more changes before the initial release?
There is no point in keeping deferring the release and I don't mind merging this as it is now. It'll be easier to iterate in separate PRs and improving over time later as we use the primitive and learn from it.
But you mentioned the idea of combining createPresence and createPresenceSwitch together, and there are few unresolved comments from earlier.
Let me know what you think.

@EthanStandel
Copy link
Contributor Author

Sorry everyone, this week was a product release week for a client. The past two weeks have been really busy, but I'm going to put out some updates tonight to get it in an initial place that I'm happy with.

@snnsnn I do get the awkwardness & dislike of having to declare the transition time in both the primitive and in your component. My reccomendation for how people should use this (and how I've used use-presence) is as primitive to create re-usable components, but I wouldn't recommend sprinkling calls to createPresence all over your code.

However, if you don't actually wait for the transition time before setting the state to "entered" then we never capture the state that would identify if you should be currently animating. Capturing that state isn't useful in these cases we've talked about, but it would be useful if you were doing some kind of tweening like animating an up-count of a number to a particular target. I think there are reasons that this state is worth supporting. And seeing as you have to pass a duration for the exit flow anyways, I don't think it's worth removing the entering state for the entrance flow just because we can away with it in this particular use-case.

@thetarnav I agree with @snnsnn that I find that model in transition-group (and react-transition-group) to be a less optimal and part of my motivation for this package in the first place. I really like the idea of using the state primitives themselves to manage the current state of an animation, as opposed to having something more akin to events firing & handler. Imo, transition-group feels more imperative and presence feels more declarative.

@EthanStandel
Copy link
Contributor Author

@thetarnav I put through updates to address our discussions. I chose not to merge createPresence & createPresenceSwitch, but instead to just rename createPresence to createPresenceBase and createPresenceSwitch to createPresence, store them both in createPresence.ts and only export (the new) createPresence. I updated all tests, the dev app, readme, and package.json's primitive.list to reflect this change. I definitely think in the future these should be constructed as a single utility function, but I think it's worth waiting to do that for when @snnsnn takes a swing at the state-machine based API.

I also had previously only validated whether a switching item should be mounted based on item() !== undefined. I had to change this to also check item() !== false. I ended up going with this:

const itemShouldBeMounted = <ItemType>(item: ItemType) => item !== false && item != null;

With that, in my opinion this is ready for an initial stage 0 release. Let me know if it needs anything else!

@thetarnav thetarnav merged commit a7dfa3a into solidjs-community:main May 10, 2023
@thetarnav
Copy link
Member

Alright, I'm going to publish the initial version now.

Thank you @EthanStandel for bringing this pattern to solid-primitives, I really like the idea and cannot wait to use it more. It could definitely replace the transition-group in some cases while providing a better visual experience. And it's less glitchy too (I think this is not an issue with presence: solidjs-community/solid-transition-group#34).

@snnsnn If you have an idea for further improvements you can now make a PR and we can continue the discussion there. I'm really interested in how the API could be simplified further without sacrificing on the functionality.

@davedbase Should we share this when you have the time?

@snnsnn
Copy link

snnsnn commented May 10, 2023

@thetarnav I have to travel on a short notice but I will send a PR for basic functionality this weekend. We can improve on it later on.

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

Successfully merging this pull request may close these issues.

4 participants