@ripple-ts/compat-react 0.2.153
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 +21 -0
- package/package.json +27 -0
- package/src/index.js +196 -0
- package/tests/client.d.ts +12 -0
- package/tests/index.test.ripple +304 -0
- package/tests/setup.js +32 -0
- package/tsconfig.json +27 -0
- package/types/index.d.ts +14 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Dominic Gannaway
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ripple-ts/compat-react",
|
|
3
|
+
"version": "0.2.153",
|
|
4
|
+
"description": "Ripple compatibility layer for React",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"author": "Dominic Gannaway",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/Ripple-TS/ripple.git",
|
|
11
|
+
"directory": "packages/compat-react"
|
|
12
|
+
},
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./src/index.js",
|
|
15
|
+
"./types": "./types/index.d.ts"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"react": "^19.2.0",
|
|
19
|
+
"react-dom": "^19.2.0",
|
|
20
|
+
"ripple": "0.2.153"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/react": "^19.2.2",
|
|
24
|
+
"@types/react-dom": "^19.2.2",
|
|
25
|
+
"typescript": "^5.9.2"
|
|
26
|
+
}
|
|
27
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/** @import { Tsx } from '../types' */
|
|
2
|
+
/** @import { ReactNode } from 'react' */
|
|
3
|
+
|
|
4
|
+
import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
|
|
5
|
+
import { useSyncExternalStore, useLayoutEffect, useRef, useState, Component } from 'react';
|
|
6
|
+
import { createPortal } from 'react-dom';
|
|
7
|
+
import { createRoot } from 'react-dom/client';
|
|
8
|
+
import {
|
|
9
|
+
branch,
|
|
10
|
+
with_block,
|
|
11
|
+
proxy_props,
|
|
12
|
+
set,
|
|
13
|
+
render,
|
|
14
|
+
tracked,
|
|
15
|
+
get_tracked,
|
|
16
|
+
handle_error,
|
|
17
|
+
} from 'ripple/internal/client';
|
|
18
|
+
import { Context } from 'ripple';
|
|
19
|
+
|
|
20
|
+
/** @type {Tsx} */
|
|
21
|
+
const tsx = {
|
|
22
|
+
jsx,
|
|
23
|
+
jsxs,
|
|
24
|
+
Fragment,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** @type {Context<null | { portals: Map<any, any>, update: Function}>} */
|
|
28
|
+
const PortalContext = new Context(null);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {any[] | Map<any, any>} portals
|
|
32
|
+
*/
|
|
33
|
+
function map_portals(portals) {
|
|
34
|
+
return Array.from(portals.entries()).map(([el, { component, key }], i) => {
|
|
35
|
+
return createPortal(jsx(component, {}, key), el);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createReactCompat() {
|
|
40
|
+
const root_portals = new Map();
|
|
41
|
+
/** @type {{ portals: Map<any, any>, update: Function}} */
|
|
42
|
+
const root_portal_state = { portals: root_portals, update: () => {} };
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
/**
|
|
46
|
+
* @param {HTMLElement} node
|
|
47
|
+
* @param {(tsx: Tsx) => ReactNode} children_fn
|
|
48
|
+
*/
|
|
49
|
+
createComponent(node, children_fn) {
|
|
50
|
+
const target_element = document.createElement('span');
|
|
51
|
+
target_element.style.display = 'contents';
|
|
52
|
+
node.before(target_element);
|
|
53
|
+
|
|
54
|
+
/** @type {(() => void) | undefined} */
|
|
55
|
+
let trigger;
|
|
56
|
+
/** @type {(() => void) | undefined} */
|
|
57
|
+
let teardown;
|
|
58
|
+
/** @type {ReactNode} */
|
|
59
|
+
let react_node;
|
|
60
|
+
|
|
61
|
+
const e = render(() => {
|
|
62
|
+
react_node = children_fn(tsx);
|
|
63
|
+
trigger?.();
|
|
64
|
+
});
|
|
65
|
+
// @ts-ignore
|
|
66
|
+
target_element.__ripple_block = e;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {() => void} callback
|
|
70
|
+
*/
|
|
71
|
+
function subscribe(callback) {
|
|
72
|
+
trigger = callback;
|
|
73
|
+
|
|
74
|
+
return () => {
|
|
75
|
+
teardown?.();
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function ReactCompat() {
|
|
80
|
+
return useSyncExternalStore(subscribe, () => react_node);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
class ReactCompatBoundary extends Component {
|
|
84
|
+
state = { e: false };
|
|
85
|
+
|
|
86
|
+
static getDerivedStateFromError(error) {
|
|
87
|
+
return { e: true };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
componentDidCatch(error) {
|
|
91
|
+
handle_error(error, e);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
render() {
|
|
95
|
+
if (this.state?.e) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
return jsx(ReactCompat, {});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const key = Math.random().toString(36).substring(2, 9);
|
|
103
|
+
const { portals, update } = PortalContext.get() || root_portal_state;
|
|
104
|
+
portals.set(target_element, { component: ReactCompatBoundary, key });
|
|
105
|
+
update();
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
createRoot() {
|
|
109
|
+
const root_element = document.createElement('span');
|
|
110
|
+
|
|
111
|
+
function CompatRoot() {
|
|
112
|
+
const [, root_update] = useState(0);
|
|
113
|
+
root_portal_state.update = root_update;
|
|
114
|
+
|
|
115
|
+
return map_portals(root_portals);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const root = createRoot(root_element);
|
|
119
|
+
root.render(jsx(CompatRoot, {}));
|
|
120
|
+
|
|
121
|
+
return () => {
|
|
122
|
+
root.unmount();
|
|
123
|
+
};
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* @param {HTMLSpanElement} node
|
|
130
|
+
*/
|
|
131
|
+
function get_block_from_dom(node) {
|
|
132
|
+
/** @type {null | ParentNode} */
|
|
133
|
+
let current = node;
|
|
134
|
+
while (current) {
|
|
135
|
+
const b = /** @type {any} */ (current).__ripple_block;
|
|
136
|
+
if (b) {
|
|
137
|
+
return /** @type {any} */ (b);
|
|
138
|
+
}
|
|
139
|
+
current = current.parentNode;
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @template P
|
|
146
|
+
* @param {{ component: (anchor: Node, props: any) => void; props?: P }} props
|
|
147
|
+
* @returns {React.JSX.Element}
|
|
148
|
+
*/
|
|
149
|
+
export function Ripple({ component, props }) {
|
|
150
|
+
const ref = useRef(null);
|
|
151
|
+
const tracked_props_ref = useRef(/** @type {any} */ (null));
|
|
152
|
+
const portals_ref = /** @type {React.MutableRefObject<Map<any, any> | null>} */ (useRef(null));
|
|
153
|
+
const [, update] = useState(0);
|
|
154
|
+
|
|
155
|
+
if (portals_ref.current === null) {
|
|
156
|
+
portals_ref.current = new Map();
|
|
157
|
+
}
|
|
158
|
+
const portals = portals_ref.current;
|
|
159
|
+
|
|
160
|
+
useLayoutEffect(() => {
|
|
161
|
+
const span = /** @type {HTMLSpanElement | null} */ (ref.current);
|
|
162
|
+
if (span === null) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const frag = document.createDocumentFragment();
|
|
166
|
+
const anchor = document.createTextNode('');
|
|
167
|
+
const block = get_block_from_dom(span);
|
|
168
|
+
const tracked_props = (tracked_props_ref.current = tracked(props || {}, block));
|
|
169
|
+
const proxied_props = proxy_props(() => get_tracked(tracked_props));
|
|
170
|
+
frag.append(anchor);
|
|
171
|
+
|
|
172
|
+
const b = with_block(block, () => {
|
|
173
|
+
PortalContext.set({ portals, update });
|
|
174
|
+
return branch(() => {
|
|
175
|
+
component(anchor, proxied_props);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
span.append(frag);
|
|
180
|
+
|
|
181
|
+
return () => {
|
|
182
|
+
anchor.remove();
|
|
183
|
+
};
|
|
184
|
+
}, [component]);
|
|
185
|
+
|
|
186
|
+
useLayoutEffect(() => {
|
|
187
|
+
set(/** @type {any} */ (tracked_props_ref.current), props || {});
|
|
188
|
+
}, [props]);
|
|
189
|
+
|
|
190
|
+
return jsx(Fragment, {
|
|
191
|
+
children: [
|
|
192
|
+
jsx('span', { ref, style: { display: 'contents' } }, 'target'),
|
|
193
|
+
...map_portals(portals),
|
|
194
|
+
],
|
|
195
|
+
});
|
|
196
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
declare var container: HTMLDivElement;
|
|
2
|
+
declare var error: string | undefined;
|
|
3
|
+
declare function render(component: () => void): void;
|
|
4
|
+
|
|
5
|
+
interface HTMLElement {
|
|
6
|
+
// We don't care about checking if it returned an element or null in tests
|
|
7
|
+
// because if it returned null, those tests will fail anyway. This
|
|
8
|
+
// typing drastically simplifies testing: you don't have to check if the
|
|
9
|
+
// query returned null or an actual element, and you don't have to do
|
|
10
|
+
// optional chaining everywhere (elem?.textContent)
|
|
11
|
+
querySelector(selectors: string): HTMLElement;
|
|
12
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { track, flushSync } from 'ripple';
|
|
2
|
+
import { act } from 'react';
|
|
3
|
+
|
|
4
|
+
describe('compat-react', () => {
|
|
5
|
+
it('should render basic React JSX inside tsx:react tags', async () => {
|
|
6
|
+
component App() {
|
|
7
|
+
<div>
|
|
8
|
+
<h1>{'Hello from Ripple'}</h1>
|
|
9
|
+
<tsx:react>
|
|
10
|
+
<div className="react-content">
|
|
11
|
+
{'Hello from React'}
|
|
12
|
+
</div>
|
|
13
|
+
</tsx:react>
|
|
14
|
+
</div>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
await act(async () => {
|
|
18
|
+
render(App);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const rippleHeading = container.querySelector('h1');
|
|
22
|
+
const reactDiv = container.querySelector('.react-content');
|
|
23
|
+
expect(rippleHeading).toBeTruthy();
|
|
24
|
+
expect(rippleHeading.textContent).toBe('Hello from Ripple');
|
|
25
|
+
expect(reactDiv).toBeTruthy();
|
|
26
|
+
expect(reactDiv.textContent).toBe('Hello from React');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('should render React fragments inside tsx:react tags', async () => {
|
|
30
|
+
component App() {
|
|
31
|
+
<div>
|
|
32
|
+
<tsx:react>
|
|
33
|
+
<>
|
|
34
|
+
<span className="first">
|
|
35
|
+
{'First'}
|
|
36
|
+
</span>
|
|
37
|
+
<span className="second">
|
|
38
|
+
{'Second'}
|
|
39
|
+
</span>
|
|
40
|
+
</>
|
|
41
|
+
</tsx:react>
|
|
42
|
+
</div>
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
await act(async () => {
|
|
46
|
+
render(App);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const first = container.querySelector('.first');
|
|
50
|
+
const second = container.querySelector('.second');
|
|
51
|
+
expect(first).toBeTruthy();
|
|
52
|
+
expect(first.textContent).toBe('First');
|
|
53
|
+
expect(second).toBeTruthy();
|
|
54
|
+
expect(second.textContent).toBe('Second');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should render nested React components', async () => {
|
|
58
|
+
component App() {
|
|
59
|
+
<div>
|
|
60
|
+
<tsx:react>
|
|
61
|
+
<div className="wrapper">
|
|
62
|
+
<div className="inner">
|
|
63
|
+
<span className="content">
|
|
64
|
+
{'Nested content'}
|
|
65
|
+
</span>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
</tsx:react>
|
|
69
|
+
</div>
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
await act(async () => {
|
|
73
|
+
render(App);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const wrapper = container.querySelector('.wrapper');
|
|
77
|
+
const inner = container.querySelector('.inner');
|
|
78
|
+
const content = container.querySelector('.content');
|
|
79
|
+
expect(wrapper).toBeTruthy();
|
|
80
|
+
expect(inner).toBeTruthy();
|
|
81
|
+
expect(content).toBeTruthy();
|
|
82
|
+
expect(content.textContent).toBe('Nested content');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should mix Ripple and React content', async () => {
|
|
86
|
+
component App() {
|
|
87
|
+
<div class="container">
|
|
88
|
+
<div class="ripple">{'This is Ripple'}</div>
|
|
89
|
+
<tsx:react>
|
|
90
|
+
<div className="react">
|
|
91
|
+
{'This is React'}
|
|
92
|
+
</div>
|
|
93
|
+
</tsx:react>
|
|
94
|
+
<div class="ripple-2">{'Back to Ripple'}</div>
|
|
95
|
+
</div>
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
await act(async () => {
|
|
99
|
+
render(App);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const rippleDiv = container.querySelector('.ripple');
|
|
103
|
+
const reactDiv = container.querySelector('.react');
|
|
104
|
+
const rippleDiv2 = container.querySelector('.ripple-2');
|
|
105
|
+
expect(rippleDiv).toBeTruthy();
|
|
106
|
+
expect(rippleDiv.textContent).toBe('This is Ripple');
|
|
107
|
+
expect(reactDiv).toBeTruthy();
|
|
108
|
+
expect(reactDiv.textContent).toBe('This is React');
|
|
109
|
+
expect(rippleDiv2).toBeTruthy();
|
|
110
|
+
expect(rippleDiv2.textContent).toBe('Back to Ripple');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('should handle multiple tsx:react blocks', async () => {
|
|
114
|
+
component App() {
|
|
115
|
+
<div>
|
|
116
|
+
<tsx:react>
|
|
117
|
+
<div className="react-1">
|
|
118
|
+
{'React Block 1'}
|
|
119
|
+
</div>
|
|
120
|
+
</tsx:react>
|
|
121
|
+
<div class="ripple-middle">{'Ripple in between'}</div>
|
|
122
|
+
<tsx:react>
|
|
123
|
+
<div className="react-2">
|
|
124
|
+
{'React Block 2'}
|
|
125
|
+
</div>
|
|
126
|
+
</tsx:react>
|
|
127
|
+
</div>
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
await act(async () => {
|
|
131
|
+
render(App);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const react1 = container.querySelector('.react-1');
|
|
135
|
+
const middle = container.querySelector('.ripple-middle');
|
|
136
|
+
const react2 = container.querySelector('.react-2');
|
|
137
|
+
expect(react1).toBeTruthy();
|
|
138
|
+
expect(react1.textContent).toBe('React Block 1');
|
|
139
|
+
expect(middle).toBeTruthy();
|
|
140
|
+
expect(middle.textContent).toBe('Ripple in between');
|
|
141
|
+
expect(react2).toBeTruthy();
|
|
142
|
+
expect(react2.textContent).toBe('React Block 2');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('should handle React components with attributes', async () => {
|
|
146
|
+
component App() {
|
|
147
|
+
<div>
|
|
148
|
+
<tsx:react>
|
|
149
|
+
<div className="react" id="test-id">
|
|
150
|
+
<span>
|
|
151
|
+
{'Content'}
|
|
152
|
+
</span>
|
|
153
|
+
</div>
|
|
154
|
+
</tsx:react>
|
|
155
|
+
</div>
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
await act(async () => {
|
|
159
|
+
render(App);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const reactDiv = container.querySelector('.react');
|
|
163
|
+
expect(reactDiv).toBeTruthy();
|
|
164
|
+
expect(reactDiv.id).toBe('test-id');
|
|
165
|
+
expect(reactDiv.querySelector('span').textContent).toBe('Content');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('should handle nested fragments', async () => {
|
|
169
|
+
component App() {
|
|
170
|
+
<div>
|
|
171
|
+
<tsx:react>
|
|
172
|
+
<>
|
|
173
|
+
<div className="outer">
|
|
174
|
+
{'Outer'}
|
|
175
|
+
</div>
|
|
176
|
+
<>
|
|
177
|
+
<div className="inner">
|
|
178
|
+
{'Inner'}
|
|
179
|
+
</div>
|
|
180
|
+
</>
|
|
181
|
+
</>
|
|
182
|
+
</tsx:react>
|
|
183
|
+
</div>
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
await act(async () => {
|
|
187
|
+
render(App);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const outer = container.querySelector('.outer');
|
|
191
|
+
const inner = container.querySelector('.inner');
|
|
192
|
+
expect(outer).toBeTruthy();
|
|
193
|
+
expect(outer.textContent).toBe('Outer');
|
|
194
|
+
expect(inner).toBeTruthy();
|
|
195
|
+
expect(inner.textContent).toBe('Inner');
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('should handle complex nested structures', async () => {
|
|
199
|
+
component App() {
|
|
200
|
+
<div>
|
|
201
|
+
<tsx:react>
|
|
202
|
+
<div className="list">
|
|
203
|
+
<ul>
|
|
204
|
+
<li>
|
|
205
|
+
{'Item 1'}
|
|
206
|
+
</li>
|
|
207
|
+
<li>
|
|
208
|
+
{'Item 2'}
|
|
209
|
+
</li>
|
|
210
|
+
<li>
|
|
211
|
+
{'Item 3'}
|
|
212
|
+
</li>
|
|
213
|
+
</ul>
|
|
214
|
+
</div>
|
|
215
|
+
</tsx:react>
|
|
216
|
+
</div>
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
await act(async () => {
|
|
220
|
+
render(App);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const list = container.querySelector('.list');
|
|
224
|
+
const items = container.querySelectorAll('li');
|
|
225
|
+
expect(list).toBeTruthy();
|
|
226
|
+
expect(items.length).toBe(3);
|
|
227
|
+
expect(items[0].textContent).toBe('Item 1');
|
|
228
|
+
expect(items[1].textContent).toBe('Item 2');
|
|
229
|
+
expect(items[2].textContent).toBe('Item 3');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('should handle empty fragments', async () => {
|
|
233
|
+
component App() {
|
|
234
|
+
<div>
|
|
235
|
+
<tsx:react>
|
|
236
|
+
<></>
|
|
237
|
+
</tsx:react>
|
|
238
|
+
<div class="after">{'After empty fragment'}</div>
|
|
239
|
+
</div>
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
await act(async () => {
|
|
243
|
+
render(App);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const after = container.querySelector('.after');
|
|
247
|
+
expect(after).toBeTruthy();
|
|
248
|
+
expect(after.textContent).toBe('After empty fragment');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('should work with Ripple reactivity', async () => {
|
|
252
|
+
component App() {
|
|
253
|
+
let count = track(0);
|
|
254
|
+
<div>
|
|
255
|
+
<div class="ripple-count">{@count}</div>
|
|
256
|
+
<button onClick={() => @count++}>{'Increment'}</button>
|
|
257
|
+
<tsx:react>
|
|
258
|
+
<div className="react-message">
|
|
259
|
+
{'React content is static'}
|
|
260
|
+
</div>
|
|
261
|
+
</tsx:react>
|
|
262
|
+
</div>
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
await act(async () => {
|
|
266
|
+
render(App);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
const rippleCount = container.querySelector('.ripple-count');
|
|
270
|
+
const button = container.querySelector('button');
|
|
271
|
+
const reactMessage = container.querySelector('.react-message');
|
|
272
|
+
expect(rippleCount.textContent).toBe('0');
|
|
273
|
+
expect(reactMessage.textContent).toBe('React content is static');
|
|
274
|
+
button.click();
|
|
275
|
+
flushSync();
|
|
276
|
+
expect(rippleCount.textContent).toBe('1');
|
|
277
|
+
expect(reactMessage.textContent).toBe('React content is static');
|
|
278
|
+
button.click();
|
|
279
|
+
flushSync();
|
|
280
|
+
expect(rippleCount.textContent).toBe('2');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('should handle a call expression at the top-level of a tsx:react block', async () => {
|
|
284
|
+
function renderReactText() {
|
|
285
|
+
return 'This is rendered from a React component!';
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
component App() {
|
|
289
|
+
<div>
|
|
290
|
+
<tsx:react>
|
|
291
|
+
{renderReactText()}
|
|
292
|
+
</tsx:react>
|
|
293
|
+
</div>
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
await act(async () => {
|
|
297
|
+
render(App);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
const reactContent = container.querySelector('div > div');
|
|
301
|
+
expect(reactContent).toBeTruthy();
|
|
302
|
+
expect(reactContent.textContent).toBe('This is rendered from a React component!');
|
|
303
|
+
});
|
|
304
|
+
});
|
package/tests/setup.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mount } from 'ripple';
|
|
3
|
+
import { createReactCompat } from '../src/index.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {() => void} component
|
|
7
|
+
*/
|
|
8
|
+
globalThis.render = function render(component) {
|
|
9
|
+
mount(component, {
|
|
10
|
+
target: /** @type {HTMLDivElement} */ (globalThis.container),
|
|
11
|
+
compat: {
|
|
12
|
+
react: createReactCompat(),
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
globalThis.container = /** @type {HTMLDivElement} */ (document.createElement('div'));
|
|
19
|
+
document.body.appendChild(globalThis.container);
|
|
20
|
+
|
|
21
|
+
globalThis.error = undefined;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
// Container is guaranteed to exist in all tests, so it was easier to type it without undefined.
|
|
26
|
+
// And when we unset it, we just type-cast it to HTMLDivElement to avoid TS errors, because we
|
|
27
|
+
// know it's guaranteed to exist in the next test again.
|
|
28
|
+
document.body.removeChild(/** @type {HTMLDivElement} */ (globalThis.container));
|
|
29
|
+
globalThis.container = /** @type {HTMLDivElement} */ (/** @type {unknown} */ (undefined));
|
|
30
|
+
|
|
31
|
+
globalThis.error = undefined;
|
|
32
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"module": "esnext",
|
|
4
|
+
"lib": ["esnext", "dom", "dom.iterable"],
|
|
5
|
+
"target": "esnext",
|
|
6
|
+
"noEmit": true,
|
|
7
|
+
"moduleResolution": "bundler",
|
|
8
|
+
"resolveJsonModule": true,
|
|
9
|
+
"noEmitOnError": true,
|
|
10
|
+
"noErrorTruncation": true,
|
|
11
|
+
"allowSyntheticDefaultImports": true,
|
|
12
|
+
"verbatimModuleSyntax": true,
|
|
13
|
+
"types": ["node", "vitest/globals"],
|
|
14
|
+
"jsx": "preserve",
|
|
15
|
+
"jsxImportSource": "ripple",
|
|
16
|
+
"strict": true,
|
|
17
|
+
"allowJs": true,
|
|
18
|
+
"checkJs": true
|
|
19
|
+
},
|
|
20
|
+
"include": [
|
|
21
|
+
"./*.js",
|
|
22
|
+
"./src/",
|
|
23
|
+
"./tests/**/*.test.ripple",
|
|
24
|
+
"./tests/**/*.d.ts",
|
|
25
|
+
"./tests/**/*.js"
|
|
26
|
+
]
|
|
27
|
+
}
|
package/types/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Component } from 'ripple';
|
|
2
|
+
|
|
3
|
+
export type Tsx = {
|
|
4
|
+
jsx: typeof import('react/jsx-runtime').jsx;
|
|
5
|
+
jsxs: typeof import('react/jsx-runtime').jsxs;
|
|
6
|
+
Fragment: typeof import('react').Fragment;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export declare function createReactCompat(): {
|
|
10
|
+
createComponent(node: HTMLElement, children_fn: (tsx: Tsx) => any): void;
|
|
11
|
+
createRoot(): () => void | (() => void);
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export declare function Ripple<P>(component: Component<P>, props?: P): React.JSX.Element;
|