@octanejs/transition-group 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,30 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2018, React Community
4
+ Forked from React (https://github.com/facebook/react) Copyright 2013-present, Facebook, Inc.
5
+ All rights reserved.
6
+
7
+ Redistribution and use in source and binary forms, with or without
8
+ modification, are permitted provided that the following conditions are met:
9
+
10
+ * Redistributions of source code must retain the above copyright notice, this
11
+ list of conditions and the following disclaimer.
12
+
13
+ * Redistributions in binary form must reproduce the above copyright notice,
14
+ this list of conditions and the following disclaimer in the documentation
15
+ and/or other materials provided with the distribution.
16
+
17
+ * Neither the name of the copyright holder nor the names of its
18
+ contributors may be used to endorse or promote products derived from
19
+ this software without specific prior written permission.
20
+
21
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
22
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
23
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
24
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
25
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
26
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
27
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
28
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
29
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
30
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package/README.md ADDED
@@ -0,0 +1,24 @@
1
+ # @octanejs/transition-group
2
+
3
+ An Octane binding for [`react-transition-group@4.4.5`](https://github.com/reactjs/react-transition-group/tree/v4.4.5).
4
+
5
+ ## Status
6
+
7
+ The package preserves React Transition Group's six public modules with native Octane components. Timed lifecycle transitions, CSS class phases, keyed group exits, and cloned keyed transitions have executable coverage.
8
+
9
+ The target public surface is:
10
+
11
+ - `Transition`
12
+ - `CSSTransition`
13
+ - `TransitionGroup`
14
+ - `SwitchTransition`
15
+ - `ReplaceTransition`
16
+ - `config`
17
+
18
+ Root imports and the matching documented per-component imports will be supported. See [UPSTREAM.md](./UPSTREAM.md) for provenance, current gaps, and intentional Octane constraints.
19
+
20
+ Use `nodeRef` for DOM-aware transitions. Octane intentionally has no `findDOMNode` equivalent. Pass dynamic `TransitionGroup` collections as an inspectable value, such as `children={items.map(...)}`.
21
+
22
+ ## License
23
+
24
+ BSD-3-Clause. The pinned upstream notice is retained in [LICENSE](./LICENSE).
package/UPSTREAM.md ADDED
@@ -0,0 +1,52 @@
1
+ # Upstream provenance
2
+
3
+ This binding is pinned to [`react-transition-group` v4.4.5](https://github.com/reactjs/react-transition-group/tree/v4.4.5), commit `4cb51a9be0ebf508cb8f6506452097f7ebb874fe`. The pristine runtime oracle uses React 18.3.1 (upstream's supported peer range and the last React that still exposes `findDOMNode`); Octane package tests continue to use the workspace React 19 oracle.
4
+
5
+ The upstream project and this adapted package are licensed under BSD-3-Clause. The upstream notice is retained in `LICENSE` and `upstream/LICENSE`.
6
+
7
+ ## Vendored evidence
8
+
9
+ `upstream/src` and `upstream/test` are byte-exact copies of the source and tests at the pinned tag. `audit/SHA256SUMS` records every retained artifact and `pnpm upstream:check` rejects drift. `pnpm upstream:verify` also checks `audit/upstream-test-dispositions.json`, `audit/case-crosswalk.json`, `audit/adapted-evidence.SHA256SUMS`, and `audit/adapted-case-contracts.json` so every upstream test artifact, case identity, adapted mapping, assertion body, and upstream probe fixture stays accounted for. File digests alone are not sufficient: the case-contract ledger pins per-case adapted/upstream assertion hashes and fixture exports against each cited upstream scenario, and negative controls refresh `adapted-evidence.SHA256SUMS` after mutation to prove the semantic contract—not a stale digest—rejects deleted assertions or weakened fixtures.
10
+
11
+ The vendored JavaScript is not published. The maintained implementation is under `src`. Adapted Octane tests are under `tests`. The strongest runtime oracle is the pristine Jest lane: `pnpm test:upstream` runs the pinned suite unchanged against React through the manifest `jest-full` executor and checks every identity against `audit/pristine-runtime.json`.
12
+
13
+ ## DefinitelyTyped type-suite provenance
14
+
15
+ The React type oracle is [`@types/react-transition-group` 4.4.12](https://www.npmjs.com/package/@types/react-transition-group/v/4.4.12). Its canonical source is DefinitelyTyped repository path `types/react-transition-group`, pinned at commit `cccd2a9ffecb708ac0606faa7f81f0b7ec535bf9`; `typesPublisherContentHash` is `28d8cc9bfe6b15e0291a07b916505b49e5cf57cfd1f331db1d9ad49fd36bfd41`. The npm tarball omits its executable type test, so `upstream-types/react-transition-group-tests.tsx` and `upstream-types/tsconfig.definitelytyped.json` are byte-exact vendored copies from that commit. The pristine runner `upstream-types/tsconfig.json` retains DT's compiler options while resolving declarations from npm rather than vendored `.d.ts` files.
16
+
17
+ `pnpm test:type-parity` runs the pristine suite with `tsc`, then its one-for-one Octane adaptation in `typetests/react-transition-group-tests.tsx` with `tsrx-tsc`. `audit/type-parity.json` pins the permitted transformations and inventories both suites; its negative controls reject a skipped file, removed JSX assertion group, removed `@ts-expect-error`, or retargeted public import.
18
+
19
+ ## Test-suite disposition
20
+
21
+ The pinned repository contains seven runtime suites plus four support files under `upstream/test`. Every artifact has a disposition in `audit/upstream-test-dispositions.json` (56 executable cases total). `audit/case-crosswalk.json` maps each upstream case to an adapted identity with a `// Per path:` citation, or records both findDOMNode cases as not-applicable. `audit/adapted-evidence.SHA256SUMS` pins adapted suite bodies and `tests/_fixtures/upstream-probes.tsrx`. `audit/adapted-case-contracts.json` records per-case assertion/fixture contracts for those mappings. Negative controls in `scripts/react-parity/react-transition-group-upstream-lib.test.mjs` reject a missing artifact disposition, a stale `caseCount`, a deleted suite, a removed case, adapted case drift, a missing citation, deleted assertions (after digest refresh), and fixture drift (after digest refresh). Adapted one-for-one coverage lives under `tests/upstream/` with `// Per path:` citations. Port-authored tests are classified in `audit/test-classifications.json`; type lanes follow the DefinitelyTyped suite under `audit/type-parity.json`.
22
+
23
+ | Upstream artifact | Disposition |
24
+ | --- | --- |
25
+ | `test/Transition-test.js` | Pristine oracle + adapted in `tests/upstream/Transition.test.ts` (both findDOMNode cases are not applicable) |
26
+ | `test/CSSTransition-test.js` | Pristine oracle + adapted in `tests/upstream/CSSTransition.test.ts` |
27
+ | `test/CSSTransitionGroup-test.js` | Pristine oracle + adapted in `tests/upstream/TransitionGroup.test.ts` |
28
+ | `test/TransitionGroup-test.js` | Pristine oracle + adapted in `tests/upstream/TransitionGroup.test.ts` (StrictMode double-appear is divergence `react-transition-group-no-strict-double-appear`) |
29
+ | `test/SwitchTransition-test.js` | Pristine oracle + adapted in `tests/upstream/SwitchTransition.test.ts` |
30
+ | `test/ChildMapping-test.js` | Pristine oracle + adapted in `tests/upstream/ChildMapping.test.ts` |
31
+ | `test/SSR-test.js` | Pristine oracle + adapted in `tests/ssr/upstream-import.test.ts` |
32
+ | `test/setup.js`, `setupAfterEnv.js`, `utils.js`, `.eslintrc.yml` | Support artifacts for the pristine Jest runner |
33
+
34
+ ## Public surface
35
+
36
+ The published React package exposes six root modules and the Octane package preserves each mapping:
37
+
38
+ | React import | Octane import |
39
+ | --- | --- |
40
+ | `react-transition-group` | `@octanejs/transition-group` |
41
+ | `react-transition-group/Transition` | `@octanejs/transition-group/Transition` |
42
+ | `react-transition-group/CSSTransition` | `@octanejs/transition-group/CSSTransition` |
43
+ | `react-transition-group/TransitionGroup` | `@octanejs/transition-group/TransitionGroup` |
44
+ | `react-transition-group/SwitchTransition` | `@octanejs/transition-group/SwitchTransition` |
45
+ | `react-transition-group/ReplaceTransition` | `@octanejs/transition-group/ReplaceTransition` |
46
+ | `react-transition-group/config` | `@octanejs/transition-group/config` |
47
+
48
+ ## Adaptation notes
49
+
50
+ Octane has no `ReactDOM.findDOMNode` equivalent. DOM-aware callbacks and `CSSTransition` therefore require `nodeRef`; callers that omit it still receive lifecycle timing and state transitions, but no inferred DOM node. This follows React Transition Group's recommended `nodeRef` path and avoids a legacy API that React Strict Mode deprecates.
51
+
52
+ Compiler-generated Octane children blocks are distinguished from genuine render props with `isChildrenBlock`. Introspective collection components should pass descriptor collections through the `children` prop (for example, `children={items.map(...)}`), so keys remain inspectable by `TransitionGroup`.
package/package.json ADDED
@@ -0,0 +1,83 @@
1
+ {
2
+ "name": "@octanejs/transition-group",
3
+ "version": "0.0.1",
4
+ "license": "BSD-3-Clause",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "engines": {
8
+ "node": ">=22.22.2"
9
+ },
10
+ "description": "React Transition Group-compatible transition components for Octane.",
11
+ "author": {
12
+ "name": "Dominic Gannaway",
13
+ "email": "dg@domgan.com"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/octanejs/octane.git",
21
+ "directory": "packages/transition-group"
22
+ },
23
+ "main": "src/index.ts",
24
+ "module": "src/index.ts",
25
+ "types": "src/index.ts",
26
+ "files": [
27
+ "src",
28
+ "LICENSE",
29
+ "README.md",
30
+ "UPSTREAM.md",
31
+ "status.json"
32
+ ],
33
+ "exports": {
34
+ ".": "./src/index.ts",
35
+ "./Transition": "./src/Transition.tsrx",
36
+ "./CSSTransition": "./src/CSSTransition.tsrx",
37
+ "./TransitionGroup": "./src/TransitionGroup.tsrx",
38
+ "./SwitchTransition": "./src/SwitchTransition.tsrx",
39
+ "./ReplaceTransition": "./src/ReplaceTransition.tsrx",
40
+ "./config": "./src/config.ts"
41
+ },
42
+ "peerDependencies": {
43
+ "octane": "0.1.35"
44
+ },
45
+ "devDependencies": {
46
+ "@babel/core": "^7.28.0",
47
+ "@babel/preset-env": "^7.28.0",
48
+ "@babel/preset-react": "^7.27.1",
49
+ "@testing-library/react": "^14.3.1",
50
+ "@tsrx/react": "^0.2.56",
51
+ "@types/react": "^19.2.17",
52
+ "@types/react-dom": "^19.2.3",
53
+ "babel-jest": "30.4.1",
54
+ "babel-plugin-add-module-exports": "^1.0.4",
55
+ "dom-helpers": "^5.2.1",
56
+ "jest": "30.4.2",
57
+ "jest-environment-jsdom": "30.4.1",
58
+ "jest-environment-node": "30.4.1",
59
+ "prop-types": "^15.8.1",
60
+ "react": "^19.2.7",
61
+ "react-dom": "^19.2.7",
62
+ "react18": "npm:react@18.3.1",
63
+ "react-dom18": "npm:react-dom@18.3.1",
64
+ "react-transition-group": "4.4.5",
65
+ "typescript": "^5.9.3",
66
+ "vitest": "^4.1.10",
67
+ "@types/react-transition-group": "^4.4.12",
68
+ "@octanejs/testing-library": "0.1.32",
69
+ "octane": "0.1.35"
70
+ },
71
+ "scripts": {
72
+ "test": "pnpm upstream:check && pnpm upstream:verify && pnpm test:upstream && vitest run --root ../.. --project transition-group --project transition-group-ssr",
73
+ "parity:inventory": "node ../../scripts/react-parity/react-transition-group-runtime-inventory.mjs",
74
+ "test:upstream": "jest --config tests/upstream-jest.config.cjs --rootDir upstream --runInBand --no-watchman",
75
+ "upstream:check": "node audit/check-upstream.mjs",
76
+ "upstream:verify": "node ../../scripts/react-parity/react-transition-group-upstream.mjs",
77
+ "parity:pristine-inventory": "node ../../scripts/react-parity/react-transition-group-pristine-runtime.mjs --write",
78
+ "typecheck": "tsrx-tsc --noEmit -p tsconfig.json && tsrx-tsc --noEmit -p typetests/tsconfig.json",
79
+ "test:type-parity": "tsc --noEmit -p upstream-types/tsconfig.json && tsrx-tsc --noEmit -p typetests/tsconfig.json",
80
+ "parity:types": "node ../../scripts/react-parity/react-transition-group-types.mjs",
81
+ "parity:types:write": "node ../../scripts/react-parity/react-transition-group-types.mjs --write"
82
+ }
83
+ }
@@ -0,0 +1,112 @@
1
+ import { useRef } from 'octane';
2
+ import Transition from './Transition.tsrx';
3
+ import type { CSSClassNames, CSSTransitionProps } from './types.ts';
4
+ import { forceReflow } from './utils/reflow.ts';
5
+
6
+ type TransitionType = 'appear' | 'enter' | 'exit';
7
+ type Phase = 'base' | 'active' | 'done';
8
+ type AppliedClasses = Record<TransitionType, Partial<Record<Phase, string>>>;
9
+
10
+ function classesFor(classNames: CSSClassNames | undefined, type: TransitionType) {
11
+ if (typeof classNames === 'string' || classNames === undefined) {
12
+ const prefix = classNames ? `${classNames}-` : '';
13
+ const base = `${prefix}${type}`;
14
+ return { base, active: `${base}-active`, done: `${base}-done` };
15
+ }
16
+ return {
17
+ base: classNames[type],
18
+ active: classNames[`${type}Active`],
19
+ done: classNames[`${type}Done`],
20
+ };
21
+ }
22
+
23
+ function addClasses(node: Element | null | undefined, value: string | undefined) {
24
+ if (!node || !value) return;
25
+ for (const className of value.split(' ')) if (className) node.classList.add(className);
26
+ }
27
+
28
+ function removeClasses(node: Element | null | undefined, value: string | undefined) {
29
+ if (!node || !value) return;
30
+ for (const className of value.split(' ')) if (className) node.classList.remove(className);
31
+ }
32
+
33
+ export function CSSTransition(props: CSSTransitionProps) @{
34
+ const applied = useRef<AppliedClasses>({ appear: {}, enter: {}, exit: {} });
35
+ const node = () => props.nodeRef?.current;
36
+ const resolveEnterArguments = (maybeNode?: Element | boolean, maybeAppearing?: boolean) =>
37
+ props.nodeRef
38
+ ? { node: props.nodeRef.current, appearing: maybeNode === true }
39
+ : { node: maybeNode as Element | undefined, appearing: maybeAppearing === true };
40
+
41
+ const clear = (type: TransitionType) => {
42
+ const current = applied.current[type];
43
+ removeClasses(node(), current.base);
44
+ removeClasses(node(), current.active);
45
+ removeClasses(node(), current.done);
46
+ applied.current[type] = {};
47
+ };
48
+
49
+ const add = (type: TransitionType, phase: Phase) => {
50
+ let className = classesFor(props.classNames, type)[phase];
51
+ if (type === 'appear' && phase === 'done') {
52
+ const enterDone = classesFor(props.classNames, 'enter').done;
53
+ if (enterDone) className = className ? `${className} ${enterDone}` : enterDone;
54
+ }
55
+ if (phase === 'active' && node()) forceReflow(node()!);
56
+ if (className) {
57
+ applied.current[type][phase] = className;
58
+ addClasses(node(), className);
59
+ }
60
+ };
61
+
62
+ const {
63
+ children,
64
+ classNames: _classNames,
65
+ onEnter,
66
+ onEntering,
67
+ onEntered,
68
+ onExit,
69
+ onExiting,
70
+ onExited,
71
+ ...transitionProps
72
+ } = props;
73
+
74
+ <Transition
75
+ {...transitionProps}
76
+ onEnter={(maybeNode, maybeAppearing) => {
77
+ const { appearing } = resolveEnterArguments(maybeNode, maybeAppearing);
78
+ clear('exit');
79
+ add(appearing ? 'appear' : 'enter', 'base');
80
+ onEnter?.(maybeNode, maybeAppearing);
81
+ }}
82
+ onEntering={(maybeNode, maybeAppearing) => {
83
+ const { appearing } = resolveEnterArguments(maybeNode, maybeAppearing);
84
+ add(appearing ? 'appear' : 'enter', 'active');
85
+ onEntering?.(maybeNode, maybeAppearing);
86
+ }}
87
+ onEntered={(maybeNode, maybeAppearing) => {
88
+ const { appearing } = resolveEnterArguments(maybeNode, maybeAppearing);
89
+ const type = appearing ? 'appear' : 'enter';
90
+ clear(type);
91
+ add(type, 'done');
92
+ onEntered?.(maybeNode, maybeAppearing);
93
+ }}
94
+ onExit={(maybeNode) => {
95
+ clear('appear');
96
+ clear('enter');
97
+ add('exit', 'base');
98
+ onExit?.(maybeNode);
99
+ }}
100
+ onExiting={(maybeNode) => {
101
+ add('exit', 'active');
102
+ onExiting?.(maybeNode);
103
+ }}
104
+ onExited={(maybeNode) => {
105
+ clear('exit');
106
+ add('exit', 'done');
107
+ onExited?.(maybeNode);
108
+ }}
109
+ >{children}</Transition>
110
+ }
111
+
112
+ export default CSSTransition;
@@ -0,0 +1,44 @@
1
+ import { Children, cloneElement, type ElementDescriptor } from 'octane';
2
+ import TransitionGroup from './TransitionGroup.tsrx';
3
+ import type { ReplaceTransitionProps, TransitionCallbacks } from './types.ts';
4
+
5
+ export function ReplaceTransition(props: ReplaceTransitionProps) @{
6
+ const [first, second] = Children.toArray(props.children) as ElementDescriptor[];
7
+ if (!first || !second) throw new Error('"children" must be exactly two transition components.');
8
+
9
+ const lifecycle = (child: ElementDescriptor, name: keyof TransitionCallbacks) => (
10
+ ...args: unknown[]
11
+ ) => {
12
+ (child.props[name] as ((...values: unknown[]) => void) | undefined)?.(...args);
13
+ (props[name] as ((...values: unknown[]) => void) | undefined)?.(undefined);
14
+ };
15
+ const {
16
+ children: _children,
17
+ in: inProp,
18
+ onEnter: _onEnter,
19
+ onEntering: _onEntering,
20
+ onEntered: _onEntered,
21
+ onExit: _onExit,
22
+ onExiting: _onExiting,
23
+ onExited: _onExited,
24
+ ...groupProps
25
+ } = props;
26
+ const selected =
27
+ inProp
28
+ ? cloneElement(first, {
29
+ key: 'first',
30
+ onEnter: lifecycle(first, 'onEnter'),
31
+ onEntering: lifecycle(first, 'onEntering'),
32
+ onEntered: lifecycle(first, 'onEntered'),
33
+ })
34
+ : cloneElement(second, {
35
+ key: 'second',
36
+ onEnter: lifecycle(second, 'onExit'),
37
+ onEntering: lifecycle(second, 'onExiting'),
38
+ onEntered: lifecycle(second, 'onExited'),
39
+ });
40
+
41
+ <TransitionGroup {...groupProps} children={[selected]} />
42
+ }
43
+
44
+ export default ReplaceTransition;
@@ -0,0 +1,105 @@
1
+ import {
2
+ cloneElement,
3
+ isValidElement,
4
+ useLayoutEffect,
5
+ useRef,
6
+ useState,
7
+ type ElementDescriptor,
8
+ } from 'octane';
9
+ import { ENTERED, ENTERING, EXITING } from './Transition.tsrx';
10
+ import TransitionGroupContext from './TransitionGroupContext.ts';
11
+ import type { SwitchTransitionProps, TransitionCallbacks, TransitionStatus } from './types.ts';
12
+
13
+ export const modes = { out: 'out-in', in: 'in-out' } as const;
14
+
15
+ function different(a: ElementDescriptor | null, b: ElementDescriptor | null | undefined) {
16
+ if (a === b) return false;
17
+ return !(isValidElement(a) && isValidElement(b) && a.key != null && a.key === b.key);
18
+ }
19
+
20
+ function chain(element: ElementDescriptor, name: keyof TransitionCallbacks, callback: () => void) {
21
+ return (...args: unknown[]) => {
22
+ (element.props[name] as ((...values: unknown[]) => void) | undefined)?.(...args);
23
+ callback();
24
+ };
25
+ }
26
+
27
+ export function SwitchTransition(props: SwitchTransitionProps) @{
28
+ const appeared = useRef(false);
29
+ const status = useRef<TransitionStatus>(ENTERED);
30
+ const current = useRef<ElementDescriptor | null>(
31
+ props.children ? cloneElement(props.children, { in: true }) : null,
32
+ );
33
+ const [, rerender] = useState(0);
34
+ const pending = props.children ?? null;
35
+ const mode = props.mode ?? modes.out;
36
+
37
+ useLayoutEffect(() => {
38
+ appeared.current = true;
39
+ }, []);
40
+
41
+ if (!pending) {
42
+ current.current = null;
43
+ status.current = ENTERED;
44
+ } else if (status.current === ENTERING && mode === modes.in) {
45
+ // Keep the in-out pair stable until the entering child finishes.
46
+ } else if (current.current && different(current.current, pending)) {
47
+ status.current = EXITING;
48
+ } else {
49
+ current.current = cloneElement(pending, { in: true });
50
+ }
51
+
52
+ let output: unknown = current.current;
53
+ if (status.current === EXITING && current.current && pending) {
54
+ if (mode === modes.out) {
55
+ output = cloneElement(current.current, {
56
+ in: false,
57
+ onExited: chain(current.current, 'onExited', () => {
58
+ current.current = null;
59
+ status.current = ENTERING;
60
+ rerender((value) => value + 1);
61
+ }),
62
+ });
63
+ } else {
64
+ output = [
65
+ current.current,
66
+ cloneElement(pending, {
67
+ in: true,
68
+ onEntered: chain(pending, 'onEntered', () => {
69
+ status.current = ENTERING;
70
+ rerender((value) => value + 1);
71
+ }),
72
+ }),
73
+ ];
74
+ }
75
+ } else if (status.current === ENTERING && pending) {
76
+ if (mode === modes.out) {
77
+ output = cloneElement(pending, {
78
+ in: true,
79
+ onEntered: chain(pending, 'onEntered', () => {
80
+ current.current = cloneElement(pending, { in: true });
81
+ status.current = ENTERED;
82
+ rerender((value) => value + 1);
83
+ }),
84
+ });
85
+ } else if (current.current) {
86
+ output = [
87
+ cloneElement(current.current, {
88
+ in: false,
89
+ onExited: chain(current.current, 'onExited', () => {
90
+ current.current = cloneElement(pending, { in: true });
91
+ status.current = ENTERED;
92
+ rerender((value) => value + 1);
93
+ }),
94
+ }),
95
+ cloneElement(pending, { in: true }),
96
+ ];
97
+ }
98
+ }
99
+
100
+ <TransitionGroupContext.Provider
101
+ value={{ isMounting: !appeared.current }}
102
+ >{output}</TransitionGroupContext.Provider>
103
+ }
104
+
105
+ export default SwitchTransition;
@@ -0,0 +1,267 @@
1
+ import {
2
+ Children,
3
+ cloneElement,
4
+ flushSync,
5
+ isChildrenBlock,
6
+ useContext,
7
+ useLayoutEffect,
8
+ useRef,
9
+ useState,
10
+ type OctaneNode,
11
+ } from 'octane';
12
+ import config from './config.ts';
13
+ import TransitionGroupContext from './TransitionGroupContext.ts';
14
+ import type {
15
+ TransitionComponent,
16
+ TransitionProps,
17
+ TransitionStatus,
18
+ TransitionTimeout,
19
+ } from './types.ts';
20
+ import { forceReflow } from './utils/reflow.ts';
21
+
22
+ export const UNMOUNTED = 'unmounted';
23
+ export const EXITED = 'exited';
24
+ export const ENTERING = 'entering';
25
+ export const ENTERED = 'entered';
26
+ export const EXITING = 'exiting';
27
+
28
+ function getTimeouts(timeout: TransitionTimeout | undefined) {
29
+ if (typeof timeout === 'number' || timeout === undefined) {
30
+ return { appear: timeout, enter: timeout, exit: timeout };
31
+ }
32
+ return {
33
+ appear: timeout.appear === undefined ? timeout.enter : timeout.appear,
34
+ enter: timeout.enter,
35
+ exit: timeout.exit,
36
+ };
37
+ }
38
+
39
+ function callEnterCallback(
40
+ callback: TransitionProps['onEnter'],
41
+ nodeRef: TransitionProps['nodeRef'],
42
+ node: Element | undefined,
43
+ appearing: boolean,
44
+ ) {
45
+ if (nodeRef) (callback as ((isAppearing: boolean) => void) | undefined)?.(appearing);
46
+ else callback?.(node, appearing);
47
+ }
48
+
49
+ function TransitionImpl(props: TransitionProps) @{
50
+ const group = useContext(TransitionGroupContext);
51
+ const latestProps = useRef(props);
52
+ latestProps.current = props;
53
+ const initial = useRef<
54
+ { status: TransitionStatus; appearStatus: TransitionStatus | null } | null
55
+ >(null);
56
+ if (initial.current === null) {
57
+ const appearing =
58
+ group && !group.isMounting ? props.enter !== false : props.appear === true;
59
+ if (props.in) {
60
+ initial.current = appearing
61
+ ? { status: EXITED, appearStatus: ENTERING }
62
+ : { status: ENTERED, appearStatus: null };
63
+ } else {
64
+ initial.current = {
65
+ status: props.unmountOnExit || props.mountOnEnter ? UNMOUNTED : EXITED,
66
+ appearStatus: null,
67
+ };
68
+ }
69
+ }
70
+
71
+ const [status, setStatus] = useState<TransitionStatus>(initial.current.status);
72
+ const mounted = useRef(false);
73
+ const generation = useRef(0);
74
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
75
+ const previousIn = useRef(props.in === true);
76
+ // Upstream calls onEntering/onExiting/onEntered/onExited from setState
77
+ // callbacks so the committed status is observable. Schedule those after the
78
+ // matching status commit instead of firing them synchronously with setState.
79
+ const pendingPhase = useRef<{ status: TransitionStatus; run: () => void } | null>(null);
80
+
81
+ const cancelPending = () => {
82
+ generation.current += 1;
83
+ pendingPhase.current = null;
84
+ if (timer.current !== null) {
85
+ clearTimeout(timer.current);
86
+ timer.current = null;
87
+ }
88
+ };
89
+
90
+ const finishAfter = (timeout: number | undefined, done: () => void) => {
91
+ const run = generation.current;
92
+ let active = true;
93
+ const finish = () => {
94
+ if (!active || run !== generation.current || !mounted.current) return;
95
+ active = false;
96
+ timer.current = null;
97
+ done();
98
+ };
99
+ const live = latestProps.current;
100
+ const node = live.nodeRef?.current ?? undefined;
101
+ if (live.nodeRef && !node) {
102
+ timer.current = setTimeout(finish, 0);
103
+ return;
104
+ }
105
+ if (live.addEndListener) {
106
+ if (live.nodeRef) (live.addEndListener as (done: () => void) => void)(finish);
107
+ else if (node) (live.addEndListener as (node: Element, done: () => void) => void)(
108
+ node,
109
+ finish,
110
+ );
111
+ }
112
+ if (active && timeout !== undefined) timer.current = setTimeout(finish, timeout);
113
+ else if (active && (!live.addEndListener || !node)) timer.current = setTimeout(finish, 0);
114
+ };
115
+
116
+ const commitStatus = (next: TransitionStatus, run: () => void) => {
117
+ pendingPhase.current = { status: next, run };
118
+ setStatus(next);
119
+ };
120
+
121
+ // Final ENTERED/EXITED completions must run before the current timeout
122
+ // callback yields — otherwise fake-timer suites can fire a slower
123
+ // addEndListener in the same runAllTimers pass before onEntered.
124
+ const commitStatusNow = (next: TransitionStatus, run: () => void) => {
125
+ pendingPhase.current = { status: next, run };
126
+ flushSync(function applyStatus() {
127
+ setStatus(next);
128
+ });
129
+ if (pendingPhase.current && pendingPhase.current.status === next) {
130
+ const pending = pendingPhase.current;
131
+ pendingPhase.current = null;
132
+ pending.run();
133
+ }
134
+ };
135
+
136
+ const performEnter = (mounting: boolean) => {
137
+ cancelPending();
138
+ const appearing = group ? group.isMounting : mounting;
139
+ const node = props.nodeRef?.current ?? undefined;
140
+ if (!mounting && props.enter === false || config.disabled) {
141
+ commitStatus(ENTERED, function afterEntered() {
142
+ const live = latestProps.current;
143
+ callEnterCallback(live.onEntered, live.nodeRef, node, appearing);
144
+ });
145
+ return;
146
+ }
147
+ callEnterCallback(props.onEnter, props.nodeRef, node, appearing);
148
+ const timeouts = getTimeouts(props.timeout);
149
+ commitStatus(ENTERING, function afterEntering() {
150
+ const live = latestProps.current;
151
+ callEnterCallback(live.onEntering, live.nodeRef, node, appearing);
152
+ finishAfter(appearing ? timeouts.appear : timeouts.enter, function enterDone() {
153
+ commitStatusNow(ENTERED, function afterEntered() {
154
+ const live = latestProps.current;
155
+ callEnterCallback(live.onEntered, live.nodeRef, node, appearing);
156
+ });
157
+ });
158
+ });
159
+ };
160
+
161
+ const performExit = () => {
162
+ cancelPending();
163
+ const node = props.nodeRef?.current ?? undefined;
164
+ if (props.exit === false || config.disabled) {
165
+ commitStatus(EXITED, function afterExited() {
166
+ const live = latestProps.current;
167
+ live.onExited?.(live.nodeRef ? undefined : node);
168
+ });
169
+ return;
170
+ }
171
+ props.onExit?.(props.nodeRef ? undefined : node);
172
+ commitStatus(EXITING, function afterExiting() {
173
+ const live = latestProps.current;
174
+ live.onExiting?.(live.nodeRef ? undefined : node);
175
+ finishAfter(getTimeouts(live.timeout).exit, function exitDone() {
176
+ commitStatusNow(EXITED, function afterExited() {
177
+ const live = latestProps.current;
178
+ live.onExited?.(live.nodeRef ? undefined : node);
179
+ });
180
+ });
181
+ });
182
+ };
183
+
184
+ useLayoutEffect(() => {
185
+ mounted.current = true;
186
+ if (initial.current!.appearStatus === ENTERING) {
187
+ initial.current!.appearStatus = null;
188
+ performEnter(true);
189
+ }
190
+ return () => {
191
+ mounted.current = false;
192
+ cancelPending();
193
+ };
194
+ }, []);
195
+
196
+ useLayoutEffect(() => {
197
+ const nextIn = props.in === true;
198
+ if (nextIn !== previousIn.current) {
199
+ if (nextIn) {
200
+ if (status === UNMOUNTED) {
201
+ setStatus(EXITED);
202
+ return;
203
+ }
204
+ previousIn.current = true;
205
+ if (props.unmountOnExit || props.mountOnEnter) {
206
+ const node = props.nodeRef?.current;
207
+ if (node) forceReflow(node);
208
+ }
209
+ performEnter(false);
210
+ } else if (status === ENTERING || status === ENTERED) {
211
+ previousIn.current = false;
212
+ performExit();
213
+ } else {
214
+ previousIn.current = false;
215
+ }
216
+ } else if (!nextIn && props.unmountOnExit && status === EXITED) {
217
+ setStatus(UNMOUNTED);
218
+ }
219
+ }, null);
220
+
221
+ useLayoutEffect(() => {
222
+ const pending = pendingPhase.current;
223
+ if (pending && pending.status === status) {
224
+ pendingPhase.current = null;
225
+ pending.run();
226
+ }
227
+ }, [status]);
228
+
229
+ let rendered: OctaneNode = null;
230
+ if (status !== UNMOUNTED) {
231
+ const {
232
+ children,
233
+ in: _in,
234
+ mountOnEnter: _mountOnEnter,
235
+ unmountOnExit: _unmountOnExit,
236
+ appear: _appear,
237
+ enter: _enter,
238
+ exit: _exit,
239
+ timeout: _timeout,
240
+ addEndListener: _addEndListener,
241
+ onEnter: _onEnter,
242
+ onEntering: _onEntering,
243
+ onEntered: _onEntered,
244
+ onExit: _onExit,
245
+ onExiting: _onExiting,
246
+ onExited: _onExited,
247
+ nodeRef: _nodeRef,
248
+ ...childProps
249
+ } = props;
250
+ rendered = typeof children === 'function' && !isChildrenBlock(children)
251
+ ? children(status, childProps)
252
+ : isChildrenBlock(children)
253
+ ? children
254
+ : cloneElement(Children.only(children), childProps);
255
+ }
256
+
257
+ <TransitionGroupContext.Provider value={null}>{rendered}</TransitionGroupContext.Provider>
258
+ }
259
+
260
+ export const Transition = TransitionImpl as unknown as TransitionComponent;
261
+ Transition.UNMOUNTED = UNMOUNTED;
262
+ Transition.EXITED = EXITED;
263
+ Transition.ENTERING = ENTERING;
264
+ Transition.ENTERED = ENTERED;
265
+ Transition.EXITING = EXITING;
266
+
267
+ export default Transition;
@@ -0,0 +1,70 @@
1
+ import { createElement, useLayoutEffect, useRef, useState, type ElementDescriptor } from 'octane';
2
+ import TransitionGroupContext from './TransitionGroupContext.ts';
3
+ import type { TransitionGroupProps } from './types.ts';
4
+ import {
5
+ getChildMapping,
6
+ getInitialChildMapping,
7
+ getNextChildMapping,
8
+ type ChildMapping,
9
+ } from './utils/ChildMapping.ts';
10
+
11
+ export function TransitionGroup(props: TransitionGroupProps) @{
12
+ const mounted = useRef(false);
13
+ const first = useRef(true);
14
+ const mapping = useRef<ChildMapping>({});
15
+ const children = useRef(props.children);
16
+ const [isMounting, setIsMounting] = useState(true);
17
+ const [, rerender] = useState(0);
18
+
19
+ // Match upstream TransitionGroup.handleExited: read live children, forward
20
+ // onExited, then delete only if the key was not synchronously re-added.
21
+ // A sync re-add re-renders and rebuilds mapping; deleting afterward would
22
+ // drop that survivor and force a second enter on the next pass.
23
+ const handleExited = function handleExited(child: ElementDescriptor, node?: Element) {
24
+ if (String(child.key) in getChildMapping(children.current)) return;
25
+ (child.props.onExited as ((node?: Element) => void) | undefined)?.(node);
26
+ if (String(child.key) in getChildMapping(children.current)) return;
27
+ if (mounted.current) {
28
+ delete mapping.current[String(child.key)];
29
+ rerender(function bump(value: number) {
30
+ return value + 1;
31
+ });
32
+ }
33
+ };
34
+
35
+ if (first.current) {
36
+ mapping.current = getInitialChildMapping(props, handleExited);
37
+ children.current = props.children;
38
+ first.current = false;
39
+ } else {
40
+ mapping.current = getNextChildMapping(props, mapping.current, handleExited);
41
+ children.current = props.children;
42
+ }
43
+
44
+ useLayoutEffect(() => {
45
+ mounted.current = true;
46
+ setIsMounting(false);
47
+ return () => {
48
+ mounted.current = false;
49
+ };
50
+ }, []);
51
+
52
+ const {
53
+ children: _children,
54
+ component = 'div',
55
+ childFactory = (child: ElementDescriptor) => child,
56
+ appear: _appear,
57
+ enter: _enter,
58
+ exit: _exit,
59
+ ...wrapperProps
60
+ } = props;
61
+ const renderedChildren = Object.values(mapping.current).map(childFactory);
62
+ const output =
63
+ component === null
64
+ ? renderedChildren
65
+ : createElement(component, wrapperProps, ...renderedChildren);
66
+
67
+ <TransitionGroupContext.Provider value={{ isMounting }}>{output}</TransitionGroupContext.Provider>
68
+ }
69
+
70
+ export default TransitionGroup;
@@ -0,0 +1,9 @@
1
+ import { createContext } from 'octane';
2
+
3
+ export interface TransitionGroupContextValue {
4
+ isMounting: boolean;
5
+ }
6
+
7
+ const TransitionGroupContext = createContext<TransitionGroupContextValue | null>(null);
8
+
9
+ export default TransitionGroupContext;
package/src/config.ts ADDED
@@ -0,0 +1,3 @@
1
+ const config = { disabled: false };
2
+
3
+ export default config;
package/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ export { default as CSSTransition } from './CSSTransition.tsrx';
2
+ export { default as ReplaceTransition } from './ReplaceTransition.tsrx';
3
+ export { default as SwitchTransition } from './SwitchTransition.tsrx';
4
+ export { default as TransitionGroup } from './TransitionGroup.tsrx';
5
+ export { default as Transition } from './Transition.tsrx';
6
+ export { default as config } from './config.ts';
7
+
8
+ export type {
9
+ CSSClassNames,
10
+ CSSTransitionProps,
11
+ ReplaceTransitionProps,
12
+ SwitchTransitionProps,
13
+ TransitionCallbacks,
14
+ TransitionGroupProps,
15
+ TransitionNodeRef,
16
+ TransitionProps,
17
+ TransitionStatus,
18
+ TransitionTimeout,
19
+ } from './types.ts';
package/src/types.ts ADDED
@@ -0,0 +1,77 @@
1
+ import type { ComponentBody, ElementDescriptor, OctaneNode } from 'octane';
2
+
3
+ export type TransitionStatus = 'unmounted' | 'exited' | 'entering' | 'entered' | 'exiting';
4
+ export type TransitionTimeout = number | { appear?: number; enter?: number; exit?: number };
5
+ export type TransitionNodeRef = { current: HTMLElement | null };
6
+
7
+ export interface TransitionCallbacks {
8
+ onEnter?: (...args: any[]) => void;
9
+ onEntering?: (...args: any[]) => void;
10
+ onEntered?: (...args: any[]) => void;
11
+ onExit?: (...args: any[]) => void;
12
+ onExiting?: (...args: any[]) => void;
13
+ onExited?: (...args: any[]) => void;
14
+ }
15
+
16
+ export interface TransitionProps extends TransitionCallbacks {
17
+ children?:
18
+ | ElementDescriptor
19
+ | ((status: TransitionStatus, childProps?: Record<string, unknown>) => OctaneNode);
20
+ in?: boolean;
21
+ mountOnEnter?: boolean;
22
+ unmountOnExit?: boolean;
23
+ appear?: boolean;
24
+ enter?: boolean;
25
+ exit?: boolean;
26
+ timeout?: TransitionTimeout;
27
+ addEndListener?: (...args: any[]) => void;
28
+ nodeRef?: TransitionNodeRef;
29
+ [key: string]: unknown;
30
+ }
31
+
32
+ export type TransitionComponent = ComponentBody<TransitionProps> & {
33
+ UNMOUNTED: 'unmounted';
34
+ EXITED: 'exited';
35
+ ENTERING: 'entering';
36
+ ENTERED: 'entered';
37
+ EXITING: 'exiting';
38
+ };
39
+
40
+ export type CSSClassNames =
41
+ | string
42
+ | {
43
+ appear?: string;
44
+ appearActive?: string;
45
+ appearDone?: string;
46
+ enter?: string;
47
+ enterActive?: string;
48
+ enterDone?: string;
49
+ exit?: string;
50
+ exitActive?: string;
51
+ exitDone?: string;
52
+ };
53
+
54
+ export interface CSSTransitionProps extends TransitionProps {
55
+ classNames?: CSSClassNames;
56
+ }
57
+
58
+ export interface TransitionGroupProps {
59
+ children?: OctaneNode;
60
+ component?: ComponentBody<any> | ((props: any) => unknown) | string | null;
61
+ childFactory?: (child: ElementDescriptor) => ElementDescriptor;
62
+ appear?: boolean;
63
+ enter?: boolean;
64
+ exit?: boolean;
65
+ [key: string]: unknown;
66
+ }
67
+
68
+ export interface SwitchTransitionProps {
69
+ children?: ElementDescriptor | null;
70
+ mode?: 'out-in' | 'in-out';
71
+ }
72
+
73
+ export interface ReplaceTransitionProps extends TransitionCallbacks {
74
+ children: OctaneNode;
75
+ in: boolean;
76
+ [key: string]: unknown;
77
+ }
@@ -0,0 +1,112 @@
1
+ import {
2
+ Children,
3
+ cloneElement,
4
+ isValidElement,
5
+ type ElementDescriptor,
6
+ type OctaneNode,
7
+ } from 'octane';
8
+ import type { TransitionGroupProps } from '../types.ts';
9
+
10
+ export type ChildMapping = Record<string, ElementDescriptor>;
11
+
12
+ export function getChildMapping(
13
+ children: OctaneNode,
14
+ mapFn?: (child: ElementDescriptor) => ElementDescriptor,
15
+ ): ChildMapping {
16
+ const result = Object.create(null) as ChildMapping;
17
+ for (const child of Children.toArray(children)) {
18
+ if (!isValidElement(child)) continue;
19
+ const mapped = mapFn ? mapFn(child) : child;
20
+ result[String(mapped.key)] = mapped;
21
+ }
22
+ return result;
23
+ }
24
+
25
+ export function mergeChildMappings(prev: ChildMapping = {}, next: ChildMapping = {}): ChildMapping {
26
+ const getValue = (key: string) => (key in next ? next[key] : prev[key]);
27
+ const pendingBefore: Record<string, string[]> = Object.create(null);
28
+ let pending: string[] = [];
29
+ for (const key of Object.keys(prev)) {
30
+ if (key in next) {
31
+ if (pending.length) {
32
+ pendingBefore[key] = pending;
33
+ pending = [];
34
+ }
35
+ } else pending.push(key);
36
+ }
37
+ const merged: ChildMapping = {};
38
+ for (const key of Object.keys(next)) {
39
+ for (const waiting of pendingBefore[key] ?? []) merged[waiting] = getValue(waiting);
40
+ merged[key] = getValue(key);
41
+ }
42
+ for (const key of pending) merged[key] = getValue(key);
43
+ return merged;
44
+ }
45
+
46
+ function inherited(
47
+ child: ElementDescriptor,
48
+ name: 'appear' | 'enter' | 'exit',
49
+ props: TransitionGroupProps,
50
+ ) {
51
+ return props[name] == null ? child.props[name] : props[name];
52
+ }
53
+
54
+ export function getInitialChildMapping(
55
+ props: TransitionGroupProps,
56
+ onExited: (child: ElementDescriptor, node?: Element) => void,
57
+ ) {
58
+ return getChildMapping(props.children, (child) =>
59
+ cloneElement(child, {
60
+ appear: inherited(child, 'appear', props),
61
+ enter: inherited(child, 'enter', props),
62
+ exit: inherited(child, 'exit', props),
63
+ in: true,
64
+ onExited: (node?: Element) => onExited(child, node),
65
+ }),
66
+ );
67
+ }
68
+
69
+ export function getNextChildMapping(
70
+ nextProps: TransitionGroupProps,
71
+ previous: ChildMapping,
72
+ onExited: (child: ElementDescriptor, node?: Element) => void,
73
+ ) {
74
+ const next = getChildMapping(nextProps.children);
75
+ const children = mergeChildMappings(previous, next);
76
+ for (const key of Object.keys(children)) {
77
+ const child = children[key];
78
+ const hasPrevious = key in previous;
79
+ const hasNext = key in next;
80
+ const previousChild = previous[key];
81
+ const isLeaving = isValidElement(previousChild) && previousChild.props.in === false;
82
+ if (hasNext && (!hasPrevious || isLeaving)) {
83
+ children[key] = cloneElement(child, {
84
+ enter: inherited(child, 'enter', nextProps),
85
+ exit: inherited(child, 'exit', nextProps),
86
+ in: true,
87
+ onExited: (node?: Element) => onExited(child, node),
88
+ });
89
+ } else if (!hasNext && hasPrevious && !isLeaving) {
90
+ // Apply the group's current exit/enter flags when a child begins
91
+ // leaving so switching TransitionGroup.exit from false → true keeps
92
+ // the outgoing node for the exit transition (upstream only sets
93
+ // `in: false`, which leaves a stale exit=false from the prior enter).
94
+ // Do not rebind onExited: the enter-time handleExited wrapper must
95
+ // remain so TransitionGroup.handleExited can forward to the user's
96
+ // original callback instead of calling itself again.
97
+ children[key] = cloneElement(child, {
98
+ in: false,
99
+ enter: inherited(child, 'enter', nextProps),
100
+ exit: inherited(child, 'exit', nextProps),
101
+ });
102
+ } else if (hasNext && hasPrevious && isValidElement(previousChild)) {
103
+ children[key] = cloneElement(child, {
104
+ enter: inherited(child, 'enter', nextProps),
105
+ exit: inherited(child, 'exit', nextProps),
106
+ in: previousChild.props.in,
107
+ onExited: (node?: Element) => onExited(child, node),
108
+ });
109
+ }
110
+ }
111
+ return children;
112
+ }
@@ -0,0 +1,3 @@
1
+ export function forceReflow(node: Element): number {
2
+ return (node as HTMLElement).scrollTop;
3
+ }
package/status.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "upstream": {
3
+ "package": "react-transition-group",
4
+ "version": "4.4.5",
5
+ "commit": "4cb51a9be0ebf508cb8f6506452097f7ebb874fe"
6
+ },
7
+ "surface": "Transition, CSSTransition, TransitionGroup, SwitchTransition, ReplaceTransition, config, and their documented subpath exports.",
8
+ "divergences": [
9
+ "Octane does not implement ReactDOM.findDOMNode; DOM-aware callbacks and CSSTransition require nodeRef.",
10
+ "TransitionGroup collections must be supplied as inspectable descriptor values, normally with children={items.map(...)}; compiler-generated opaque children blocks cannot be enumerated.",
11
+ "The complete pinned upstream suite runs unchanged as the pristine Jest oracle; adapted Octane lanes cover the public export surface, DOM transition behavior, switch and replacement sequencing, keyed groups, nodeRef semantics, mount-on-enter sequencing, and server rendering, and are not yet a one-for-one port of every upstream case."
12
+ ],
13
+ "ssr": "Transition state and wrapper markup render on the server; DOM class mutation begins only after client mount and requires nodeRef.",
14
+ "verified": "2026-08-09"
15
+ }