From 0688c8fc3e165db54b8718adbf39a6bce754621b Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sun, 22 Oct 2023 11:33:59 +0900 Subject: [PATCH 01/18] feat(tiny-transition): add `LaggedBoolean` --- packages/tiny-transition/src/index.ts | 1 + packages/tiny-transition/src/lagged/core.ts | 57 ++++++++++++++++++++ packages/tiny-transition/src/lagged/react.ts | 18 +++++++ packages/tiny-transition/src/react.tsx | 1 + 4 files changed, 77 insertions(+) create mode 100644 packages/tiny-transition/src/lagged/core.ts create mode 100644 packages/tiny-transition/src/lagged/react.ts diff --git a/packages/tiny-transition/src/index.ts b/packages/tiny-transition/src/index.ts index e17292e1..f03aaf8e 100644 --- a/packages/tiny-transition/src/index.ts +++ b/packages/tiny-transition/src/index.ts @@ -1,2 +1,3 @@ export * from "./core"; export * from "./class"; +export * from "./lagged/core"; diff --git a/packages/tiny-transition/src/lagged/core.ts b/packages/tiny-transition/src/lagged/core.ts new file mode 100644 index 00000000..f431ab97 --- /dev/null +++ b/packages/tiny-transition/src/lagged/core.ts @@ -0,0 +1,57 @@ +// inspired by discussions in solid `createPresense` +// https://github.com/solidjs-community/solid-primitives/pull/414#issuecomment-1520787178 +// https://github.com/solidjs-community/solid-primitives/pull/437 + +export type LaggedBooleanState = boolean | "trueing" | "falseing"; + +export class LaggedBoolean { + private state: LaggedBooleanState; + private listeners = new Set<() => void>(); + private timeoutId: ReturnType | undefined; + + constructor( + defaultValue: boolean, + private lagDuration: { true: number; false: number } + ) { + this.state = defaultValue; + } + + get = () => this.state; + + set(value: boolean) { + if (this.state === false && value) { + this.setLagged(true); + } else if (this.state === "trueing" && !value) { + this.setLagged(false); + } else if (this.state === true && !value) { + this.setLagged(false); + } else if (this.state === "falseing" && value) { + this.setLagged(true); + } + } + + private setLagged(value: boolean) { + if (typeof this.timeoutId !== "undefined") { + clearTimeout(this.timeoutId); + } + + this.state = `${value}ing`; + this.notify(); + + this.timeoutId = setTimeout(() => { + this.state = value; + this.notify(); + }, this.lagDuration[`${value}`]); + } + + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + private notify() { + for (const listener of this.listeners) { + listener(); + } + } +} diff --git a/packages/tiny-transition/src/lagged/react.ts b/packages/tiny-transition/src/lagged/react.ts new file mode 100644 index 00000000..8ecf3586 --- /dev/null +++ b/packages/tiny-transition/src/lagged/react.ts @@ -0,0 +1,18 @@ +import { useState, useSyncExternalStore } from "react"; +import { LaggedBoolean, type LaggedBooleanState } from "./core"; + +export function useLaggedBoolean( + value: boolean, + lagDuration: number | { true: number; false: number } +): LaggedBooleanState { + const [lagged] = useState( + () => + new LaggedBoolean( + value, + typeof lagDuration === "number" + ? { true: lagDuration, false: lagDuration } + : lagDuration + ) + ); + return useSyncExternalStore(lagged.subscribe, lagged.get, lagged.get); +} diff --git a/packages/tiny-transition/src/react.tsx b/packages/tiny-transition/src/react.tsx index 7c0a1255..6fe52a15 100644 --- a/packages/tiny-transition/src/react.tsx +++ b/packages/tiny-transition/src/react.tsx @@ -11,6 +11,7 @@ import { type TransitionCallbackProps, TransitionManager, } from "./core"; +export * from "./lagged/react"; // cheat typing to simplify dts rollup function simpleForawrdRef< From 34a4875f06cc1161dc3b976c1cc4ff9978b76d6c Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sun, 22 Oct 2023 12:16:27 +0900 Subject: [PATCH 02/18] chore: setup (broken) example --- packages/app/src/components/stories.tsx | 45 ++++++++++++++++---- packages/tiny-transition/src/lagged/core.ts | 12 +++--- packages/tiny-transition/src/lagged/react.ts | 7 ++- 3 files changed, 49 insertions(+), 15 deletions(-) diff --git a/packages/app/src/components/stories.tsx b/packages/app/src/components/stories.tsx index 98afaaf8..60b4ba84 100644 --- a/packages/app/src/components/stories.tsx +++ b/packages/app/src/components/stories.tsx @@ -3,7 +3,10 @@ import { useTinyForm } from "@hiogawa/tiny-form/dist/react"; import { useTinyProgress } from "@hiogawa/tiny-progress/dist/react"; import { useTinyStoreStorage } from "@hiogawa/tiny-store/dist/react"; import { TOAST_POSITIONS, type ToastPosition } from "@hiogawa/tiny-toast"; -import { Transition } from "@hiogawa/tiny-transition/dist/react"; +import { + Transition, + useLaggedBoolean, +} from "@hiogawa/tiny-transition/dist/react"; import { ANTD_VARS } from "@hiogawa/unocss-preset-antd"; import { none, objectKeys, objectPickBy, range } from "@hiogawa/utils"; import { Debug, toSetSetState, useDelay } from "@hiogawa/utils-react"; @@ -526,19 +529,32 @@ export function StorySlide() { export function StoryCollapse() { const [show, setShow] = React.useState(true); + const [show2, setShow2] = React.useState(true); + const lagged2 = useLaggedBoolean(show2, 500); return (

Collapse

- +
+ + +
+
+          debug: {JSON.stringify({ show, show2, lagged2 })}
+        
-
Fixed Div
+
Fixed Div (1)
Collapsable Div
+
+
Fixed Div (2)
+ {lagged2 && ( +
+
Collapsable Div
+
+ )} +
); diff --git a/packages/tiny-transition/src/lagged/core.ts b/packages/tiny-transition/src/lagged/core.ts index f431ab97..e571857a 100644 --- a/packages/tiny-transition/src/lagged/core.ts +++ b/packages/tiny-transition/src/lagged/core.ts @@ -2,6 +2,9 @@ // https://github.com/solidjs-community/solid-primitives/pull/414#issuecomment-1520787178 // https://github.com/solidjs-community/solid-primitives/pull/437 +// this is not enough to achieve +// left -(true)-> enterFrom -(next frame)-> enterTo -(timeout)-> entered + export type LaggedBooleanState = boolean | "trueing" | "falseing"; export class LaggedBoolean { @@ -19,14 +22,11 @@ export class LaggedBoolean { get = () => this.state; set(value: boolean) { - if (this.state === false && value) { + if (value && (this.state === false || this.state === "falseing")) { this.setLagged(true); - } else if (this.state === "trueing" && !value) { - this.setLagged(false); - } else if (this.state === true && !value) { + } + if (!value && (this.state === true || this.state === "trueing")) { this.setLagged(false); - } else if (this.state === "falseing" && value) { - this.setLagged(true); } } diff --git a/packages/tiny-transition/src/lagged/react.ts b/packages/tiny-transition/src/lagged/react.ts index 8ecf3586..bec1451c 100644 --- a/packages/tiny-transition/src/lagged/react.ts +++ b/packages/tiny-transition/src/lagged/react.ts @@ -1,4 +1,4 @@ -import { useState, useSyncExternalStore } from "react"; +import { useLayoutEffect, useState, useSyncExternalStore } from "react"; import { LaggedBoolean, type LaggedBooleanState } from "./core"; export function useLaggedBoolean( @@ -14,5 +14,10 @@ export function useLaggedBoolean( : lagDuration ) ); + + useLayoutEffect(() => { + lagged.set(value); + }, [value]); + return useSyncExternalStore(lagged.subscribe, lagged.get, lagged.get); } From 18d0fa0c876ec58ea48e09a5e16be475bef5d852 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sun, 22 Oct 2023 12:57:42 +0900 Subject: [PATCH 03/18] fix: 6 states --- packages/app/src/components/stories.tsx | 23 ++++- packages/tiny-transition/src/lagged/core.ts | 96 +++++++++++++++++--- packages/tiny-transition/src/lagged/react.ts | 7 +- 3 files changed, 105 insertions(+), 21 deletions(-) diff --git a/packages/app/src/components/stories.tsx b/packages/app/src/components/stories.tsx index 60b4ba84..ca163080 100644 --- a/packages/app/src/components/stories.tsx +++ b/packages/app/src/components/stories.tsx @@ -529,8 +529,14 @@ export function StorySlide() { export function StoryCollapse() { const [show, setShow] = React.useState(true); + + // experiment with "lagged boolean" approach. + // limitations are + // - collapse (height) animation requires accessing dom height directly. + // - same `duration` has to be manually set in two places + // - "appear" effect not implemented const [show2, setShow2] = React.useState(true); - const lagged2 = useLaggedBoolean(show2, 500); + const lagged = useLaggedBoolean(show2, 500); return (
@@ -551,7 +557,7 @@ export function StoryCollapse() {
-          debug: {JSON.stringify({ show, show2, lagged2 })}
+          debug: {JSON.stringify({ show, show2, lagged2: lagged })}
         
Fixed Div (1)
@@ -559,6 +565,10 @@ export function StoryCollapse() { appear show={show} className="duration-500 overflow-hidden" + enterFrom="opacity-0" + enterTo="opacity-100" + leaveFrom="opacity-100" + leaveTo="opacity-0" {...getCollapseProps()} >
Collapsable Div
@@ -566,11 +576,14 @@ export function StoryCollapse() {
Fixed Div (2)
- {lagged2 && ( + {lagged && (
Collapsable Div
diff --git a/packages/tiny-transition/src/lagged/core.ts b/packages/tiny-transition/src/lagged/core.ts index e571857a..c3e14e53 100644 --- a/packages/tiny-transition/src/lagged/core.ts +++ b/packages/tiny-transition/src/lagged/core.ts @@ -2,10 +2,16 @@ // https://github.com/solidjs-community/solid-primitives/pull/414#issuecomment-1520787178 // https://github.com/solidjs-community/solid-primitives/pull/437 -// this is not enough to achieve -// left -(true)-> enterFrom -(next frame)-> enterTo -(timeout)-> entered +// TODO: "appear" effect? -export type LaggedBooleanState = boolean | "trueing" | "falseing"; +// animation in each direction requires two intemediate states +// false -(true)-> enterFrom -(next frame)-> enterTo -(timeout)-> true +export type LaggedBooleanState = + | boolean + | "enterFrom" + | "enterTo" + | "leaveFrom" + | "leaveTo"; export class LaggedBoolean { private state: LaggedBooleanState; @@ -22,26 +28,82 @@ export class LaggedBoolean { get = () => this.state; set(value: boolean) { - if (value && (this.state === false || this.state === "falseing")) { - this.setLagged(true); - } - if (!value && (this.state === true || this.state === "trueing")) { - this.setLagged(false); + if ( + value + ? this.state === false || + this.state === "leaveTo" || + this.state === "leaveFrom" + : this.state === true || + this.state === "enterFrom" || + this.state === "enterTo" + ) { + this.setLagged(value); } + // if (value) { + // if ( + // this.state === false || + // this.state === "leaveTo" || + // this.state === "leaveFrom" + // ) { + // this.setLagged(value); + // } + // // if ( + // // this.state === false || + // // this.state === "leaveTo" + // // ) { + // // this.initState(value); + // // } else if (this.state === "enterFrom") { + // // this.startTimeout(value); + // // } else if (this.state === "leaveFrom") { + // // this.state = value; + // // } + // } else { + // if ( + // this.state === true || + // this.state === "enterFrom" || + // this.state === "enterTo" + // ) { + // this.setLagged(value); + // } + // } } + // private initState(value: boolean) { + // this.disposeTimeout(); + // this.state = value ? "enterFrom" : "leaveFrom"; + // } + + // private startTimeout(value: boolean) { + // this.disposeTimeout(); + // this.state = value ? "enterTo" : "leaveTo"; + // this.notify(); + + // this.timeoutId = setTimeout(() => { + // this.state = value; + // this.notify(); + // this.disposeTimeout(); + // }, this.lagDuration[`${value}`]); + // } + private setLagged(value: boolean) { - if (typeof this.timeoutId !== "undefined") { - clearTimeout(this.timeoutId); - } + this.disposeTimeout(); - this.state = `${value}ing`; + this.state = value ? "enterFrom" : "leaveFrom"; this.notify(); + // does react guarantee re-rendering after `notify` before `setTimeout(..., 0)`? + // otherwise, `useLaggedBoolean` might directly see "enterTo" without passing through "enterFrom". this.timeoutId = setTimeout(() => { - this.state = value; + this.state = value ? "enterTo" : "leaveTo"; this.notify(); - }, this.lagDuration[`${value}`]); + this.disposeTimeout(); + + this.timeoutId = setTimeout(() => { + this.state = value; + this.notify(); + this.disposeTimeout(); + }, this.lagDuration[`${value}`]); + }, 0); } subscribe = (listener: () => void) => { @@ -49,6 +111,12 @@ export class LaggedBoolean { return () => this.listeners.delete(listener); }; + private disposeTimeout() { + if (typeof this.timeoutId !== "undefined") { + clearTimeout(this.timeoutId); + } + } + private notify() { for (const listener of this.listeners) { listener(); diff --git a/packages/tiny-transition/src/lagged/react.ts b/packages/tiny-transition/src/lagged/react.ts index bec1451c..2e05dd60 100644 --- a/packages/tiny-transition/src/lagged/react.ts +++ b/packages/tiny-transition/src/lagged/react.ts @@ -1,4 +1,4 @@ -import { useLayoutEffect, useState, useSyncExternalStore } from "react"; +import { useEffect, useState, useSyncExternalStore } from "react"; import { LaggedBoolean, type LaggedBooleanState } from "./core"; export function useLaggedBoolean( @@ -15,7 +15,10 @@ export function useLaggedBoolean( ) ); - useLayoutEffect(() => { + // trigger transition in each effect regardless of value change + // useEffect(() => lagged.set(value)); + + useEffect(() => { lagged.set(value); }, [value]); From 17e2104e842760dc7216a1c75883af0bdf9d2d5c Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sun, 22 Oct 2023 12:59:22 +0900 Subject: [PATCH 04/18] chore: comment --- packages/tiny-transition/src/lagged/core.ts | 44 -------------------- packages/tiny-transition/src/lagged/react.ts | 3 -- 2 files changed, 47 deletions(-) diff --git a/packages/tiny-transition/src/lagged/core.ts b/packages/tiny-transition/src/lagged/core.ts index c3e14e53..794418f6 100644 --- a/packages/tiny-transition/src/lagged/core.ts +++ b/packages/tiny-transition/src/lagged/core.ts @@ -39,52 +39,8 @@ export class LaggedBoolean { ) { this.setLagged(value); } - // if (value) { - // if ( - // this.state === false || - // this.state === "leaveTo" || - // this.state === "leaveFrom" - // ) { - // this.setLagged(value); - // } - // // if ( - // // this.state === false || - // // this.state === "leaveTo" - // // ) { - // // this.initState(value); - // // } else if (this.state === "enterFrom") { - // // this.startTimeout(value); - // // } else if (this.state === "leaveFrom") { - // // this.state = value; - // // } - // } else { - // if ( - // this.state === true || - // this.state === "enterFrom" || - // this.state === "enterTo" - // ) { - // this.setLagged(value); - // } - // } } - // private initState(value: boolean) { - // this.disposeTimeout(); - // this.state = value ? "enterFrom" : "leaveFrom"; - // } - - // private startTimeout(value: boolean) { - // this.disposeTimeout(); - // this.state = value ? "enterTo" : "leaveTo"; - // this.notify(); - - // this.timeoutId = setTimeout(() => { - // this.state = value; - // this.notify(); - // this.disposeTimeout(); - // }, this.lagDuration[`${value}`]); - // } - private setLagged(value: boolean) { this.disposeTimeout(); diff --git a/packages/tiny-transition/src/lagged/react.ts b/packages/tiny-transition/src/lagged/react.ts index 2e05dd60..8f51565c 100644 --- a/packages/tiny-transition/src/lagged/react.ts +++ b/packages/tiny-transition/src/lagged/react.ts @@ -15,9 +15,6 @@ export function useLaggedBoolean( ) ); - // trigger transition in each effect regardless of value change - // useEffect(() => lagged.set(value)); - useEffect(() => { lagged.set(value); }, [value]); From 085eb6871930271d5070a39fe423b291551a7668 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sun, 22 Oct 2023 13:13:37 +0900 Subject: [PATCH 05/18] feat: support `appear` option --- packages/app/src/components/stories.tsx | 32 +++++++++++++------- packages/tiny-transition/src/lagged/core.ts | 16 +++++----- packages/tiny-transition/src/lagged/react.ts | 18 +++++------ 3 files changed, 36 insertions(+), 30 deletions(-) diff --git a/packages/app/src/components/stories.tsx b/packages/app/src/components/stories.tsx index ca163080..aec2b470 100644 --- a/packages/app/src/components/stories.tsx +++ b/packages/app/src/components/stories.tsx @@ -532,11 +532,11 @@ export function StoryCollapse() { // experiment with "lagged boolean" approach. // limitations are - // - collapse (height) animation requires accessing dom height directly. // - same `duration` has to be manually set in two places - // - "appear" effect not implemented + // - though this is a speciafic difficultly of collpase. + // height animation requires accessing dom height directly. const [show2, setShow2] = React.useState(true); - const lagged = useLaggedBoolean(show2, 500); + const lagged2 = useLaggedBoolean(show2, { duration: 500, appear: true }); return (
@@ -557,7 +557,7 @@ export function StoryCollapse() {
-          debug: {JSON.stringify({ show, show2, lagged2: lagged })}
+          debug: {JSON.stringify({ show, show2, lagged2 })}
         
Fixed Div (1)
@@ -571,22 +571,32 @@ export function StoryCollapse() { leaveTo="opacity-0" {...getCollapseProps()} > -
Collapsable Div
+
+
Collapsable Div
+
Collapsable Div
+
Collapsable Div
+
Collapsable Div
+
Fixed Div (2)
- {lagged && ( + {lagged2 && (
-
Collapsable Div
+
+
Collapsable Div
+
Collapsable Div
+
Collapsable Div
+
Collapsable Div
+
)}
diff --git a/packages/tiny-transition/src/lagged/core.ts b/packages/tiny-transition/src/lagged/core.ts index 794418f6..1d86287e 100644 --- a/packages/tiny-transition/src/lagged/core.ts +++ b/packages/tiny-transition/src/lagged/core.ts @@ -2,8 +2,6 @@ // https://github.com/solidjs-community/solid-primitives/pull/414#issuecomment-1520787178 // https://github.com/solidjs-community/solid-primitives/pull/437 -// TODO: "appear" effect? - // animation in each direction requires two intemediate states // false -(true)-> enterFrom -(next frame)-> enterTo -(timeout)-> true export type LaggedBooleanState = @@ -13,16 +11,18 @@ export type LaggedBooleanState = | "leaveFrom" | "leaveTo"; +export interface LaggedBooleanOptions { + duration: number; + appear?: boolean; +} + export class LaggedBoolean { private state: LaggedBooleanState; private listeners = new Set<() => void>(); private timeoutId: ReturnType | undefined; - constructor( - defaultValue: boolean, - private lagDuration: { true: number; false: number } - ) { - this.state = defaultValue; + constructor(value: boolean, private options: LaggedBooleanOptions) { + this.state = options?.appear ? !value : value; } get = () => this.state; @@ -58,7 +58,7 @@ export class LaggedBoolean { this.state = value; this.notify(); this.disposeTimeout(); - }, this.lagDuration[`${value}`]); + }, this.options.duration); }, 0); } diff --git a/packages/tiny-transition/src/lagged/react.ts b/packages/tiny-transition/src/lagged/react.ts index 8f51565c..cf007250 100644 --- a/packages/tiny-transition/src/lagged/react.ts +++ b/packages/tiny-transition/src/lagged/react.ts @@ -1,19 +1,15 @@ import { useEffect, useState, useSyncExternalStore } from "react"; -import { LaggedBoolean, type LaggedBooleanState } from "./core"; +import { + LaggedBoolean, + type LaggedBooleanOptions, + type LaggedBooleanState, +} from "./core"; export function useLaggedBoolean( value: boolean, - lagDuration: number | { true: number; false: number } + options: LaggedBooleanOptions ): LaggedBooleanState { - const [lagged] = useState( - () => - new LaggedBoolean( - value, - typeof lagDuration === "number" - ? { true: lagDuration, false: lagDuration } - : lagDuration - ) - ); + const [lagged] = useState(() => new LaggedBoolean(value, options)); useEffect(() => { lagged.set(value); From 812c4fa385e27ea9bee9d4476df8260cf2f1e40d Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sun, 22 Oct 2023 14:44:14 +0900 Subject: [PATCH 06/18] chore: experiment "explicit transition" --- packages/tiny-transition/src/lagged/core.ts | 32 ++++++++------- packages/tiny-transition/src/lagged/react.ts | 42 +++++++++++++++++--- 2 files changed, 54 insertions(+), 20 deletions(-) diff --git a/packages/tiny-transition/src/lagged/core.ts b/packages/tiny-transition/src/lagged/core.ts index 1d86287e..a66f2d94 100644 --- a/packages/tiny-transition/src/lagged/core.ts +++ b/packages/tiny-transition/src/lagged/core.ts @@ -2,8 +2,9 @@ // https://github.com/solidjs-community/solid-primitives/pull/414#issuecomment-1520787178 // https://github.com/solidjs-community/solid-primitives/pull/437 -// animation in each direction requires two intemediate states -// false -(true)-> enterFrom -(next frame)-> enterTo -(timeout)-> true +// animation in each direction requires two intemediate steps +// false --(true)----> enterFrom --(true)--> enterTo ---(timeout)-> true +// <-(timeout)-- leaveTo <-(false)-- leaveFrom <--(false)---- export type LaggedBooleanState = | boolean | "enterFrom" @@ -37,29 +38,30 @@ export class LaggedBoolean { this.state === "enterFrom" || this.state === "enterTo" ) { - this.setLagged(value); + this.startFrom(value); + } else if (this.state === "enterFrom" || this.state === "leaveFrom") { + setTimeout(() => { + this.startTo(value); + }, 0); } } - private setLagged(value: boolean) { + private startFrom(value: boolean) { this.disposeTimeout(); - this.state = value ? "enterFrom" : "leaveFrom"; this.notify(); + } + + private startTo(value: boolean) { + this.disposeTimeout(); + this.state = value ? "enterTo" : "leaveTo"; + this.notify(); - // does react guarantee re-rendering after `notify` before `setTimeout(..., 0)`? - // otherwise, `useLaggedBoolean` might directly see "enterTo" without passing through "enterFrom". this.timeoutId = setTimeout(() => { - this.state = value ? "enterTo" : "leaveTo"; + this.state = value; this.notify(); this.disposeTimeout(); - - this.timeoutId = setTimeout(() => { - this.state = value; - this.notify(); - this.disposeTimeout(); - }, this.options.duration); - }, 0); + }, this.options.duration); } subscribe = (listener: () => void) => { diff --git a/packages/tiny-transition/src/lagged/react.ts b/packages/tiny-transition/src/lagged/react.ts index cf007250..5f14b489 100644 --- a/packages/tiny-transition/src/lagged/react.ts +++ b/packages/tiny-transition/src/lagged/react.ts @@ -1,3 +1,4 @@ +import { once } from "@hiogawa/utils"; import { useEffect, useState, useSyncExternalStore } from "react"; import { LaggedBoolean, @@ -9,11 +10,42 @@ export function useLaggedBoolean( value: boolean, options: LaggedBooleanOptions ): LaggedBooleanState { - const [lagged] = useState(() => new LaggedBoolean(value, options)); + const [manager] = useState(() => new LaggedBoolean(value, options)); + const state = useSyncExternalStore( + manager.subscribe, + manager.get, + manager.get + ); + // once + // useEffect(() => { + // setTimeout(() => { + // console.log("(before)", manager.get(), value); + // manager.set(value); + // console.log("(after)", manager.get()); + // }, 10); + // }); + // useEffect(() => { + // console.log("(before)", { value, state }); + // manager.set(value); + // console.log("(after)", manager.get()); + // }, [value, state]); - useEffect(() => { - lagged.set(value); - }, [value]); + useEffect( + once(() => { + console.log("(before)", { value, state }); + manager.set(value); + console.log("(after)", manager.get()); + }), + [value, state] + ); - return useSyncExternalStore(lagged.subscribe, lagged.get, lagged.get); + // useEffect(once(() => { + // setTimeout(() => { + // console.log("(before)", { value, state }); + // manager.set(value); + // console.log("(after)", manager.get()); + // }, 10); + // }), [value, state]); + + return state; } From 312f2c2a8f768bab72bb1a71e5220f65f6d128b2 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sun, 22 Oct 2023 14:46:00 +0900 Subject: [PATCH 07/18] Revert "chore: experiment "explicit transition"" This reverts commit 812c4fa385e27ea9bee9d4476df8260cf2f1e40d. --- packages/tiny-transition/src/lagged/core.ts | 32 +++++++-------- packages/tiny-transition/src/lagged/react.ts | 42 +++----------------- 2 files changed, 20 insertions(+), 54 deletions(-) diff --git a/packages/tiny-transition/src/lagged/core.ts b/packages/tiny-transition/src/lagged/core.ts index a66f2d94..1d86287e 100644 --- a/packages/tiny-transition/src/lagged/core.ts +++ b/packages/tiny-transition/src/lagged/core.ts @@ -2,9 +2,8 @@ // https://github.com/solidjs-community/solid-primitives/pull/414#issuecomment-1520787178 // https://github.com/solidjs-community/solid-primitives/pull/437 -// animation in each direction requires two intemediate steps -// false --(true)----> enterFrom --(true)--> enterTo ---(timeout)-> true -// <-(timeout)-- leaveTo <-(false)-- leaveFrom <--(false)---- +// animation in each direction requires two intemediate states +// false -(true)-> enterFrom -(next frame)-> enterTo -(timeout)-> true export type LaggedBooleanState = | boolean | "enterFrom" @@ -38,30 +37,29 @@ export class LaggedBoolean { this.state === "enterFrom" || this.state === "enterTo" ) { - this.startFrom(value); - } else if (this.state === "enterFrom" || this.state === "leaveFrom") { - setTimeout(() => { - this.startTo(value); - }, 0); + this.setLagged(value); } } - private startFrom(value: boolean) { + private setLagged(value: boolean) { this.disposeTimeout(); - this.state = value ? "enterFrom" : "leaveFrom"; - this.notify(); - } - private startTo(value: boolean) { - this.disposeTimeout(); - this.state = value ? "enterTo" : "leaveTo"; + this.state = value ? "enterFrom" : "leaveFrom"; this.notify(); + // does react guarantee re-rendering after `notify` before `setTimeout(..., 0)`? + // otherwise, `useLaggedBoolean` might directly see "enterTo" without passing through "enterFrom". this.timeoutId = setTimeout(() => { - this.state = value; + this.state = value ? "enterTo" : "leaveTo"; this.notify(); this.disposeTimeout(); - }, this.options.duration); + + this.timeoutId = setTimeout(() => { + this.state = value; + this.notify(); + this.disposeTimeout(); + }, this.options.duration); + }, 0); } subscribe = (listener: () => void) => { diff --git a/packages/tiny-transition/src/lagged/react.ts b/packages/tiny-transition/src/lagged/react.ts index 5f14b489..cf007250 100644 --- a/packages/tiny-transition/src/lagged/react.ts +++ b/packages/tiny-transition/src/lagged/react.ts @@ -1,4 +1,3 @@ -import { once } from "@hiogawa/utils"; import { useEffect, useState, useSyncExternalStore } from "react"; import { LaggedBoolean, @@ -10,42 +9,11 @@ export function useLaggedBoolean( value: boolean, options: LaggedBooleanOptions ): LaggedBooleanState { - const [manager] = useState(() => new LaggedBoolean(value, options)); - const state = useSyncExternalStore( - manager.subscribe, - manager.get, - manager.get - ); - // once - // useEffect(() => { - // setTimeout(() => { - // console.log("(before)", manager.get(), value); - // manager.set(value); - // console.log("(after)", manager.get()); - // }, 10); - // }); - // useEffect(() => { - // console.log("(before)", { value, state }); - // manager.set(value); - // console.log("(after)", manager.get()); - // }, [value, state]); + const [lagged] = useState(() => new LaggedBoolean(value, options)); - useEffect( - once(() => { - console.log("(before)", { value, state }); - manager.set(value); - console.log("(after)", manager.get()); - }), - [value, state] - ); + useEffect(() => { + lagged.set(value); + }, [value]); - // useEffect(once(() => { - // setTimeout(() => { - // console.log("(before)", { value, state }); - // manager.set(value); - // console.log("(after)", manager.get()); - // }, 10); - // }), [value, state]); - - return state; + return useSyncExternalStore(lagged.subscribe, lagged.get, lagged.get); } From 0d6eb715d71e488f45369febe23b78463c404ba7 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sun, 22 Oct 2023 14:46:57 +0900 Subject: [PATCH 08/18] chore: comment --- packages/tiny-transition/src/lagged/core.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/tiny-transition/src/lagged/core.ts b/packages/tiny-transition/src/lagged/core.ts index 1d86287e..022467c5 100644 --- a/packages/tiny-transition/src/lagged/core.ts +++ b/packages/tiny-transition/src/lagged/core.ts @@ -2,8 +2,9 @@ // https://github.com/solidjs-community/solid-primitives/pull/414#issuecomment-1520787178 // https://github.com/solidjs-community/solid-primitives/pull/437 -// animation in each direction requires two intemediate states -// false -(true)-> enterFrom -(next frame)-> enterTo -(timeout)-> true +// animation in each direction requires two intemediate steps +// false --(true)----> enterFrom --(true)--> enterTo ---(timeout)-> true +// <-(timeout)-- leaveTo <-(false)-- leaveFrom <--(false)---- export type LaggedBooleanState = | boolean | "enterFrom" From 1d56e0bb60160f991226f2cdf52e6250c022e918 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sun, 22 Oct 2023 14:48:21 +0900 Subject: [PATCH 09/18] chore: minor rename --- packages/tiny-transition/src/lagged/react.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/tiny-transition/src/lagged/react.ts b/packages/tiny-transition/src/lagged/react.ts index cf007250..e3f5c9d3 100644 --- a/packages/tiny-transition/src/lagged/react.ts +++ b/packages/tiny-transition/src/lagged/react.ts @@ -9,11 +9,11 @@ export function useLaggedBoolean( value: boolean, options: LaggedBooleanOptions ): LaggedBooleanState { - const [lagged] = useState(() => new LaggedBoolean(value, options)); + const [manager] = useState(() => new LaggedBoolean(value, options)); useEffect(() => { - lagged.set(value); + manager.set(value); }, [value]); - return useSyncExternalStore(lagged.subscribe, lagged.get, lagged.get); + return useSyncExternalStore(manager.subscribe, manager.get, manager.get); } From 1989797fec3767d1f6f0f8112e063ba93702183b Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sun, 22 Oct 2023 14:50:22 +0900 Subject: [PATCH 10/18] chore: ts -> tsx --- packages/tiny-transition/src/lagged/{react.ts => react.tsx} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/tiny-transition/src/lagged/{react.ts => react.tsx} (100%) diff --git a/packages/tiny-transition/src/lagged/react.ts b/packages/tiny-transition/src/lagged/react.tsx similarity index 100% rename from packages/tiny-transition/src/lagged/react.ts rename to packages/tiny-transition/src/lagged/react.tsx From c6ef361bd0c734889f9ad3dc85d6432673cb348a Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sun, 22 Oct 2023 15:21:02 +0900 Subject: [PATCH 11/18] refactor: use next frame --- packages/tiny-transition/src/lagged/core.ts | 45 +++++++++++++-------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/packages/tiny-transition/src/lagged/core.ts b/packages/tiny-transition/src/lagged/core.ts index 022467c5..d6e5d3a9 100644 --- a/packages/tiny-transition/src/lagged/core.ts +++ b/packages/tiny-transition/src/lagged/core.ts @@ -3,8 +3,8 @@ // https://github.com/solidjs-community/solid-primitives/pull/437 // animation in each direction requires two intemediate steps -// false --(true)----> enterFrom --(true)--> enterTo ---(timeout)-> true -// <-(timeout)-- leaveTo <-(false)-- leaveFrom <--(false)---- +// false --(true)----> enterFrom --(next frame)--> enterTo ---(timeout)-> true +// <-(timeout)-- leaveTo <-(next frame)-- leaveFrom <--(false)---- export type LaggedBooleanState = | boolean | "enterFrom" @@ -20,7 +20,7 @@ export interface LaggedBooleanOptions { export class LaggedBoolean { private state: LaggedBooleanState; private listeners = new Set<() => void>(); - private timeoutId: ReturnType | undefined; + private asyncOp = new AsyncOperation(); constructor(value: boolean, private options: LaggedBooleanOptions) { this.state = options?.appear ? !value : value; @@ -43,24 +43,22 @@ export class LaggedBoolean { } private setLagged(value: boolean) { - this.disposeTimeout(); + this.asyncOp.dispose(); this.state = value ? "enterFrom" : "leaveFrom"; this.notify(); - // does react guarantee re-rendering after `notify` before `setTimeout(..., 0)`? - // otherwise, `useLaggedBoolean` might directly see "enterTo" without passing through "enterFrom". - this.timeoutId = setTimeout(() => { + // does react scheduling guarantee re-rendering between two `notify` separated by `requestAnimationFrame`? + // if that's not the case, `useLaggedBoolean` might directly see "enterTo" without passing through "enterFrom". + this.asyncOp.requestAnimationFrame(() => { this.state = value ? "enterTo" : "leaveTo"; this.notify(); - this.disposeTimeout(); - this.timeoutId = setTimeout(() => { + this.asyncOp.setTimeout(() => { this.state = value; this.notify(); - this.disposeTimeout(); }, this.options.duration); - }, 0); + }); } subscribe = (listener: () => void) => { @@ -68,15 +66,28 @@ export class LaggedBoolean { return () => this.listeners.delete(listener); }; - private disposeTimeout() { - if (typeof this.timeoutId !== "undefined") { - clearTimeout(this.timeoutId); - } - } - private notify() { for (const listener of this.listeners) { listener(); } } } + +class AsyncOperation { + private disposables = new Set<() => void>(); + + setTimeout(callback: () => void, ms: number) { + const id = setTimeout(callback, ms); + this.disposables.add(() => clearTimeout(id)); + } + + requestAnimationFrame(callback: () => void) { + const id = requestAnimationFrame(callback); + return () => cancelAnimationFrame(id); + } + + dispose() { + this.disposables.forEach((f) => f()); + this.disposables.clear(); + } +} From a0da4b88dd077cc47f0eee2a8e01f09538578ed6 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sun, 22 Oct 2023 15:28:23 +0900 Subject: [PATCH 12/18] fix: forceStyle for reliable "appear"? --- packages/tiny-transition/src/lagged/core.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/tiny-transition/src/lagged/core.ts b/packages/tiny-transition/src/lagged/core.ts index d6e5d3a9..e40d6158 100644 --- a/packages/tiny-transition/src/lagged/core.ts +++ b/packages/tiny-transition/src/lagged/core.ts @@ -51,6 +51,7 @@ export class LaggedBoolean { // does react scheduling guarantee re-rendering between two `notify` separated by `requestAnimationFrame`? // if that's not the case, `useLaggedBoolean` might directly see "enterTo" without passing through "enterFrom". this.asyncOp.requestAnimationFrame(() => { + forceStyle(); // `appear` doesn't work reliably without this? this.state = value ? "enterTo" : "leaveTo"; this.notify(); @@ -91,3 +92,7 @@ class AsyncOperation { this.disposables.clear(); } } + +function forceStyle() { + typeof document.body.offsetHeight || console.log("unreachable"); +} From 0b0a3bbf38fd1b5554509fb15eda8411059af929 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sun, 22 Oct 2023 15:31:04 +0900 Subject: [PATCH 13/18] chore: comment --- packages/tiny-transition/src/lagged/core.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tiny-transition/src/lagged/core.ts b/packages/tiny-transition/src/lagged/core.ts index e40d6158..718d478e 100644 --- a/packages/tiny-transition/src/lagged/core.ts +++ b/packages/tiny-transition/src/lagged/core.ts @@ -3,7 +3,7 @@ // https://github.com/solidjs-community/solid-primitives/pull/437 // animation in each direction requires two intemediate steps -// false --(true)----> enterFrom --(next frame)--> enterTo ---(timeout)-> true +// false --(true)----> enterFrom --(next frame)--> enterTo ---(timeout)-> true // <-(timeout)-- leaveTo <-(next frame)-- leaveFrom <--(false)---- export type LaggedBooleanState = | boolean From 277a32296568c3798a7ac53d7fb948d78f649883 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sun, 22 Oct 2023 15:58:28 +0900 Subject: [PATCH 14/18] feat: TransitionV2 (wip) --- packages/tiny-toast/package.json | 2 - packages/tiny-transition/package.json | 2 - packages/tiny-transition/src/lagged/react.tsx | 106 +++++++++++++++++- packages/tiny-transition/src/react.tsx | 2 +- packages/tiny-transition/tsconfig.json | 2 +- pnpm-lock.yaml | 16 --- 6 files changed, 105 insertions(+), 25 deletions(-) diff --git a/packages/tiny-toast/package.json b/packages/tiny-toast/package.json index 8a3465d7..e6de4871 100644 --- a/packages/tiny-toast/package.json +++ b/packages/tiny-toast/package.json @@ -41,8 +41,6 @@ "@hiogawa/tiny-react": "0.0.1-pre.10", "@hiogawa/tiny-transition": "workspace:*", "@hiogawa/unocss-preset-antd": "workspace:*", - "@hiogawa/utils": "1.6.1-pre.7", - "@hiogawa/utils-react": "^1.3.1-pre.0", "@types/react": "^18.2.14", "preact": "^10.15.1", "react": "^18.2.0" diff --git a/packages/tiny-transition/package.json b/packages/tiny-transition/package.json index f225cc58..3e9cf9c3 100644 --- a/packages/tiny-transition/package.json +++ b/packages/tiny-transition/package.json @@ -33,8 +33,6 @@ "release": "pnpm publish --no-git-checks --access public" }, "devDependencies": { - "@hiogawa/utils": "1.6.1-pre.7", - "@hiogawa/utils-react": "^1.3.1-pre.0", "@types/react": "^18.2.14", "react": "^18.2.0" }, diff --git a/packages/tiny-transition/src/lagged/react.tsx b/packages/tiny-transition/src/lagged/react.tsx index e3f5c9d3..8a386747 100644 --- a/packages/tiny-transition/src/lagged/react.tsx +++ b/packages/tiny-transition/src/lagged/react.tsx @@ -1,4 +1,23 @@ -import { useEffect, useState, useSyncExternalStore } from "react"; +import { objectMapValues, objectOmit } from "@hiogawa/utils"; +import { useMergeRefs, useStableCallback } from "@hiogawa/utils-react"; +import { + useCallback, + useEffect, + useRef, + useState, + useSyncExternalStore, +} from "react"; +import { + TRANSITION_CLASS_TYPES, + type TransitionClassProps, + convertClassPropsToCallbackProps, +} from "../class"; +import { + TRANSITION_CALLBACK_TYPES, + type TransitionCallbackProps, + type TransitionCallbackType, +} from "../core"; +import { simpleForawrdRef } from "../react"; import { LaggedBoolean, type LaggedBooleanOptions, @@ -7,7 +26,8 @@ import { export function useLaggedBoolean( value: boolean, - options: LaggedBooleanOptions + options: LaggedBooleanOptions & + Partial void>> ): LaggedBooleanState { const [manager] = useState(() => new LaggedBoolean(value, options)); @@ -15,5 +35,85 @@ export function useLaggedBoolean( manager.set(value); }, [value]); - return useSyncExternalStore(manager.subscribe, manager.get, manager.get); + return useSyncExternalStore( + useCallback((listener) => { + // implement state callback outside of core. + // we don't use `useEffect` deps since it would be clumsy to deal with StrictMode double effect. + return manager.subscribe(() => { + const state = manager.get(); + if (state === false) options.onLeft?.(); + if (state === "enterFrom") options.onEnterFrom?.(); + if (state === "enterTo") options.onEnterTo?.(); + if (state === "leaveFrom") options.onLeaveFrom?.(); + if (state === "leaveTo") options.onLeaveTo?.(); + if (state === true) options.onLeft?.(); + listener(); + }); + }, []), + manager.get, + manager.get + ); +} + +export const TransitionV2 = simpleForawrdRef(function TransitionV2( + props: { + show: boolean; + appear?: boolean; + render?: (props: Record) => React.ReactNode; + duration: number; + } & TransitionClassProps & + TransitionCallbackProps & { + // choose only common props from `JSX.IntrinsicElements["div"]` to simplify auto-complete + className?: string; + style?: React.CSSProperties; + children?: React.ReactNode; + }, + ref: React.ForwardedRef +) { + // deifne stable callbacks with ref element + const elRef = useRef(null); + const callbackProps = convertClassPropsToCallbackProps( + props.className, + props + ); + const stableCallbackPropsWithRef = objectMapValues( + callbackProps, + (callback) => + useStableCallback(() => { + if (callback && elRef.current) { + callback(elRef.current); + } + }) + ); + + // lagged state + const state = useLaggedBoolean(props.show, { + duration: props.duration, + appear: props.appear, + ...stableCallbackPropsWithRef, + }); + + // render + const mergedRefs = useMergeRefs(ref, elRef); + const render = props.render ?? defaultRender; + + return ( + <> + {state && + render({ + ref: mergedRefs, + ...objectOmit(props, [ + "show", + "appear", + "render", + ...TRANSITION_CLASS_TYPES, + ...TRANSITION_CALLBACK_TYPES, + ]), + })} + + ); +}); + +function defaultRender(props: any) { + return
; } diff --git a/packages/tiny-transition/src/react.tsx b/packages/tiny-transition/src/react.tsx index 6fe52a15..c6d31bf0 100644 --- a/packages/tiny-transition/src/react.tsx +++ b/packages/tiny-transition/src/react.tsx @@ -14,7 +14,7 @@ import { export * from "./lagged/react"; // cheat typing to simplify dts rollup -function simpleForawrdRef< +export function simpleForawrdRef< P, T, F extends (props: P, ref: React.ForwardedRef) => JSX.Element diff --git a/packages/tiny-transition/tsconfig.json b/packages/tiny-transition/tsconfig.json index 6221ff7f..4ce00595 100644 --- a/packages/tiny-transition/tsconfig.json +++ b/packages/tiny-transition/tsconfig.json @@ -3,6 +3,6 @@ "include": ["src"], "compilerOptions": { "types": ["vite/client"], - "jsx": "react" + "jsx": "react-jsx" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c857a25..0080dd99 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -208,12 +208,6 @@ importers: '@hiogawa/unocss-preset-antd': specifier: workspace:* version: link:../lib - '@hiogawa/utils': - specifier: 1.6.1-pre.7 - version: 1.6.1-pre.7 - '@hiogawa/utils-react': - specifier: ^1.3.1-pre.0 - version: 1.3.1-pre.0(react@18.2.0) '@types/react': specifier: ^18.2.14 version: 18.2.21 @@ -226,12 +220,6 @@ importers: packages/tiny-transition: devDependencies: - '@hiogawa/utils': - specifier: 1.6.1-pre.7 - version: 1.6.1-pre.7 - '@hiogawa/utils-react': - specifier: ^1.3.1-pre.0 - version: 1.3.1-pre.0(react@18.2.0) '@types/react': specifier: ^18.2.14 version: 18.2.14 @@ -1405,10 +1393,6 @@ packages: react: 18.2.0 dev: true - /@hiogawa/utils@1.6.1-pre.7: - resolution: {integrity: sha512-KFSgUTQiv8WD+ludYGiSt2QeXvlpaN+81h/eZZM++FKJQZcWTyLFNR5h1/on1TpXGxzois6D3PRMPXQwDywuUQ==} - dev: true - /@hiogawa/utils@1.6.1-pre.9: resolution: {integrity: sha512-9RnMKYYp0BAK4xsRl9V9gxH6J4wQWL9hzFLfREAWiiB6let0csGruGvmXAFu8RKfhG7/0h6xkpg/TrCO1+RsYg==} dev: true From 15128380bc35f6b444186202cf647715ae92e969 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sun, 22 Oct 2023 16:45:05 +0900 Subject: [PATCH 15/18] fix: fix "onEnterFrom" --- packages/tiny-transition/src/lagged/react.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/tiny-transition/src/lagged/react.tsx b/packages/tiny-transition/src/lagged/react.tsx index 8a386747..7e57a315 100644 --- a/packages/tiny-transition/src/lagged/react.tsx +++ b/packages/tiny-transition/src/lagged/react.tsx @@ -44,9 +44,9 @@ export function useLaggedBoolean( if (state === false) options.onLeft?.(); if (state === "enterFrom") options.onEnterFrom?.(); if (state === "enterTo") options.onEnterTo?.(); + if (state === true) options.onEntered?.(); if (state === "leaveFrom") options.onLeaveFrom?.(); if (state === "leaveTo") options.onLeaveTo?.(); - if (state === true) options.onLeft?.(); listener(); }); }, []), @@ -93,8 +93,13 @@ export const TransitionV2 = simpleForawrdRef(function TransitionV2( ...stableCallbackPropsWithRef, }); - // render - const mergedRefs = useMergeRefs(ref, elRef); + const mergedRefs = useMergeRefs(ref, elRef, (el: HTMLElement | null) => { + // hacky way to deal with `onEnterFrom` + // since "enterFrom" event comes before we render dom... + if (el) { + callbackProps.onEnterFrom?.(el); + } + }); const render = props.render ?? defaultRender; return ( From c43e4218845d1bf023f1f31b5c0f7c2a2cd2068c Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sun, 22 Oct 2023 16:53:09 +0900 Subject: [PATCH 16/18] feat: derive transition duration without explicit prop --- packages/app/src/components/stories.tsx | 5 +++-- packages/tiny-transition/src/core.ts | 3 ++- packages/tiny-transition/src/lagged/react.tsx | 15 +++++++++++---- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/app/src/components/stories.tsx b/packages/app/src/components/stories.tsx index aec2b470..9995fc5a 100644 --- a/packages/app/src/components/stories.tsx +++ b/packages/app/src/components/stories.tsx @@ -5,6 +5,7 @@ import { useTinyStoreStorage } from "@hiogawa/tiny-store/dist/react"; import { TOAST_POSITIONS, type ToastPosition } from "@hiogawa/tiny-toast"; import { Transition, + TransitionV2, useLaggedBoolean, } from "@hiogawa/tiny-transition/dist/react"; import { ANTD_VARS } from "@hiogawa/unocss-preset-antd"; @@ -510,7 +511,7 @@ export function StorySlide() { > hello from top/right - hello from bottom/left - +
diff --git a/packages/tiny-transition/src/core.ts b/packages/tiny-transition/src/core.ts index 806d0a77..3e840ca4 100644 --- a/packages/tiny-transition/src/core.ts +++ b/packages/tiny-transition/src/core.ts @@ -157,7 +157,8 @@ function onTransitionEnd(el: HTMLElement, callback: () => void) { }; } -function computeTransitionTimeout(el: HTMLElement): number { +export function computeTransitionTimeout(el: HTMLElement): number { + // this probably also `forceStyle` const style = getComputedStyle(el); const [duration, delay] = [ style.transitionDuration, diff --git a/packages/tiny-transition/src/lagged/react.tsx b/packages/tiny-transition/src/lagged/react.tsx index 7e57a315..31faf0f9 100644 --- a/packages/tiny-transition/src/lagged/react.tsx +++ b/packages/tiny-transition/src/lagged/react.tsx @@ -16,6 +16,7 @@ import { TRANSITION_CALLBACK_TYPES, type TransitionCallbackProps, type TransitionCallbackType, + computeTransitionTimeout, } from "../core"; import { simpleForawrdRef } from "../react"; import { @@ -60,7 +61,6 @@ export const TransitionV2 = simpleForawrdRef(function TransitionV2( show: boolean; appear?: boolean; render?: (props: Record) => React.ReactNode; - duration: number; } & TransitionClassProps & TransitionCallbackProps & { // choose only common props from `JSX.IntrinsicElements["div"]` to simplify auto-complete @@ -87,17 +87,24 @@ export const TransitionV2 = simpleForawrdRef(function TransitionV2( ); // lagged state + const durationRef = useRef(0); const state = useLaggedBoolean(props.show, { - duration: props.duration, + get duration() { + return durationRef.current; + }, appear: props.appear, ...stableCallbackPropsWithRef, }); const mergedRefs = useMergeRefs(ref, elRef, (el: HTMLElement | null) => { - // hacky way to deal with `onEnterFrom` - // since "enterFrom" event comes before we render dom... if (el) { + // hacky way to deal with `onEnterFrom` + // since "enterFrom" event comes before we render dom... callbackProps.onEnterFrom?.(el); + + // derive timeout duration + // (note that this "force styles" so it must be called after "onEnterFrom") + durationRef.current = computeTransitionTimeout(el); } }); const render = props.render ?? defaultRender; From 653fedcc3d8bd8c81e32c2e4e9a96e2bb71356ff Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Sun, 22 Oct 2023 19:54:58 +0900 Subject: [PATCH 17/18] fix: fix AsyncOperation --- packages/tiny-transition/src/lagged/core.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/tiny-transition/src/lagged/core.ts b/packages/tiny-transition/src/lagged/core.ts index 718d478e..eb8b8b22 100644 --- a/packages/tiny-transition/src/lagged/core.ts +++ b/packages/tiny-transition/src/lagged/core.ts @@ -84,7 +84,7 @@ class AsyncOperation { requestAnimationFrame(callback: () => void) { const id = requestAnimationFrame(callback); - return () => cancelAnimationFrame(id); + this.disposables.add(() => cancelAnimationFrame(id)); } dispose() { From e240a484cd66bca9a0334c133bd36d4871a3fd99 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Mon, 23 Oct 2023 12:49:55 +0900 Subject: [PATCH 18/18] feat: add useTransitionManagerV2 --- packages/app/src/components/stories.tsx | 16 +++++ packages/tiny-transition/src/core.ts | 2 +- packages/tiny-transition/src/lagged/core.ts | 8 +-- packages/tiny-transition/src/lagged/react.tsx | 20 ++++++ packages/tiny-transition/src/lagged/v2.ts | 71 +++++++++++++++++++ 5 files changed, 112 insertions(+), 5 deletions(-) create mode 100644 packages/tiny-transition/src/lagged/v2.ts diff --git a/packages/app/src/components/stories.tsx b/packages/app/src/components/stories.tsx index 9995fc5a..4de99026 100644 --- a/packages/app/src/components/stories.tsx +++ b/packages/app/src/components/stories.tsx @@ -7,6 +7,7 @@ import { Transition, TransitionV2, useLaggedBoolean, + useTransitionManagerV2, } from "@hiogawa/tiny-transition/dist/react"; import { ANTD_VARS } from "@hiogawa/unocss-preset-antd"; import { none, objectKeys, objectPickBy, range } from "@hiogawa/utils"; @@ -488,6 +489,7 @@ export function StoryModal() { export function StorySlide() { const [show, setShow] = React.useState(true); + const manager = useTransitionManagerV2(show, { appear: true }); return (
@@ -522,6 +524,20 @@ export function StorySlide() { > hello from bottom/left + {manager.state && ( +
+ hello from top/left +
+ )}
diff --git a/packages/tiny-transition/src/core.ts b/packages/tiny-transition/src/core.ts index 3e840ca4..b25bf273 100644 --- a/packages/tiny-transition/src/core.ts +++ b/packages/tiny-transition/src/core.ts @@ -158,7 +158,7 @@ function onTransitionEnd(el: HTMLElement, callback: () => void) { } export function computeTransitionTimeout(el: HTMLElement): number { - // this probably also `forceStyle` + // this probably also flush style/paint because of `getComputedStyle` const style = getComputedStyle(el); const [duration, delay] = [ style.transitionDuration, diff --git a/packages/tiny-transition/src/lagged/core.ts b/packages/tiny-transition/src/lagged/core.ts index eb8b8b22..985cccfe 100644 --- a/packages/tiny-transition/src/lagged/core.ts +++ b/packages/tiny-transition/src/lagged/core.ts @@ -3,8 +3,8 @@ // https://github.com/solidjs-community/solid-primitives/pull/437 // animation in each direction requires two intemediate steps -// false --(true)----> enterFrom --(next frame)--> enterTo ---(timeout)-> true -// <-(timeout)-- leaveTo <-(next frame)-- leaveFrom <--(false)---- +// false --(true)----> enterFrom --(mount + next frame)--> enterTo ---(timeout)-> true +// <-(timeout)-- leaveTo <-(next frame)----------- leaveFrom <--(false)---- export type LaggedBooleanState = | boolean | "enterFrom" @@ -74,7 +74,7 @@ export class LaggedBoolean { } } -class AsyncOperation { +export class AsyncOperation { private disposables = new Set<() => void>(); setTimeout(callback: () => void, ms: number) { @@ -93,6 +93,6 @@ class AsyncOperation { } } -function forceStyle() { +export function forceStyle() { typeof document.body.offsetHeight || console.log("unreachable"); } diff --git a/packages/tiny-transition/src/lagged/react.tsx b/packages/tiny-transition/src/lagged/react.tsx index 31faf0f9..45717737 100644 --- a/packages/tiny-transition/src/lagged/react.tsx +++ b/packages/tiny-transition/src/lagged/react.tsx @@ -24,6 +24,26 @@ import { type LaggedBooleanOptions, type LaggedBooleanState, } from "./core"; +import { TransitionManagerV2 } from "./v2"; + +export function useTransitionManagerV2( + value: boolean, + options?: { appear?: boolean } +) { + const [manager] = useState(() => new TransitionManagerV2(value, options)); + + useEffect(() => { + manager.set(value); + }, [value]); + + useSyncExternalStore( + manager.subscribe, + () => manager.state, + () => manager.state + ); + + return manager; +} export function useLaggedBoolean( value: boolean, diff --git a/packages/tiny-transition/src/lagged/v2.ts b/packages/tiny-transition/src/lagged/v2.ts new file mode 100644 index 00000000..fcae8aed --- /dev/null +++ b/packages/tiny-transition/src/lagged/v2.ts @@ -0,0 +1,71 @@ +import { computeTransitionTimeout } from "../core"; +import { AsyncOperation, type LaggedBooleanState, forceStyle } from "./core"; + +export class TransitionManagerV2 { + state: LaggedBooleanState; + private el: HTMLElement | null = null; + private listeners = new Set<() => void>(); + private asyncOp = new AsyncOperation(); + + constructor(value: boolean, options?: { appear?: boolean }) { + this.state = options?.appear ? !value : value; + } + + set(value: boolean) { + const isTruthy = + this.state === true || + this.state === "enterFrom" || + this.state === "enterTo"; + if (value !== isTruthy) { + this.startTransition(value); + } + } + + ref = (el: HTMLElement | null) => { + this.el = el; + if (el) { + if (this.state === "enterFrom") { + this.startTransition(true); + } + } else { + this.state = false; + } + }; + + private startTransition(value: boolean) { + this.asyncOp.dispose(); + + this.update(value ? "enterFrom" : "leaveFrom"); + + // delay "enterTo" transition until mount + if (!this.el) { + return; + } + + const duration = computeTransitionTimeout(this.el); + + this.asyncOp.requestAnimationFrame(() => { + forceStyle(); // `appear` breaks without this. not entirely sure why. + this.update(value ? "enterTo" : "leaveTo"); + + this.asyncOp.setTimeout(() => { + this.update(value); + }, duration); + }); + } + + private update(state: LaggedBooleanState) { + if (this.state === state) { + return; + } + this.state = state; + for (const listener of this.listeners) { + listener(); + } + } + + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; +}