fiber-tunnel 0.0.0-stage → 0.1.0
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/README.md +65 -2
- package/dist/fiber-tunnel.js +179 -0
- package/dist/index.d.ts +50 -0
- package/package.json +83 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Astralarium
|
|
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/README.md
CHANGED
|
@@ -1,3 +1,66 @@
|
|
|
1
|
-
#
|
|
1
|
+
# FiberTunnel
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
React tunnel that renders content in fiber-tree order.
|
|
4
|
+
|
|
5
|
+
[Docs](https://astralarium.github.io/fiber-tunnel/)
|
|
6
|
+
| [Github](https://github.com/astralarium/fiber-tunnel)
|
|
7
|
+
| [NPM](https://www.npmjs.com/package/@astralarium/fiber-tunnel)
|
|
8
|
+
|
|
9
|
+
## Installation
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm i @astralarium/fiber-tunnel
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Requires React 19.2 or later.
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
```tsx
|
|
20
|
+
import { fiberTunnel } from "@astralarium/fiber-tunnel";
|
|
21
|
+
import { Canvas } from "@react-three/fiber";
|
|
22
|
+
|
|
23
|
+
const overlay = fiberTunnel();
|
|
24
|
+
|
|
25
|
+
function Card({ name }: { name: string }) {
|
|
26
|
+
return (
|
|
27
|
+
<mesh>
|
|
28
|
+
<overlay.In>
|
|
29
|
+
<li>{name}</li>
|
|
30
|
+
</overlay.In>
|
|
31
|
+
</mesh>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function App() {
|
|
36
|
+
return (
|
|
37
|
+
<>
|
|
38
|
+
<ul>
|
|
39
|
+
<overlay.Out />
|
|
40
|
+
</ul>
|
|
41
|
+
<Canvas>
|
|
42
|
+
<Card name="Ace" />
|
|
43
|
+
<Card name="King" />
|
|
44
|
+
</Canvas>
|
|
45
|
+
</>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`fiberTunnel()` returns `{ In, Out, refresh }`.
|
|
51
|
+
Create tunnels at module scope or in a component with `useState`.
|
|
52
|
+
|
|
53
|
+
## Behavior
|
|
54
|
+
|
|
55
|
+
- `Out` renders content in React tree order.
|
|
56
|
+
- Wrap each nested React root's host in `<FiberAnchor>`,
|
|
57
|
+
e.g. `<FiberAnchor><Canvas>…</Canvas></FiberAnchor>`.
|
|
58
|
+
Unanchored roots have no defined order.
|
|
59
|
+
- Updating an `In` updates content in place, preserving state and DOM.
|
|
60
|
+
- Mount only one `Out` per tunnel.
|
|
61
|
+
- Call `refresh()` after reordering `In`s or anchors.
|
|
62
|
+
- Content appears one commit after `In` mounts.
|
|
63
|
+
`Out` mounted together with `In`s paints empty once.
|
|
64
|
+
SSR renders empty `Out`s until hydration.
|
|
65
|
+
|
|
66
|
+
Inspired by [tunnel-rat](https://github.com/pmndrs/tunnel-rat).
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { c as e } from "react/compiler-runtime";
|
|
3
|
+
import t, { Component as n, Fragment as r, createContext as i, useLayoutEffect as a, useRef as o, useSyncExternalStore as s } from "react";
|
|
4
|
+
import { Fragment as c, jsx as l } from "react/jsx-runtime";
|
|
5
|
+
//#region src/fiber-anchor-context.ts
|
|
6
|
+
var u = i(null);
|
|
7
|
+
//#endregion
|
|
8
|
+
//#region src/util.ts
|
|
9
|
+
function d(e) {
|
|
10
|
+
return e._reactInternals;
|
|
11
|
+
}
|
|
12
|
+
function f(e) {
|
|
13
|
+
let t = e;
|
|
14
|
+
for (; t.return;) t = t.return;
|
|
15
|
+
let n = t.stateNode;
|
|
16
|
+
return n?.current ? n : null;
|
|
17
|
+
}
|
|
18
|
+
function p(e, t, n) {
|
|
19
|
+
let r = e.current;
|
|
20
|
+
if (!r) return;
|
|
21
|
+
let i = 0, a = 0, o = [r];
|
|
22
|
+
for (; o.length > 0;) {
|
|
23
|
+
let e = o.pop();
|
|
24
|
+
if ((t.has(e) || e.alternate != null && t.has(e.alternate)) && (n.set(e, a), e.alternate && n.set(e.alternate, a), ++i >= t.size)) return;
|
|
25
|
+
a++, e.sibling && o.push(e.sibling), e.child && o.push(e.child);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function m(e, t) {
|
|
29
|
+
return e === t || e.alternate === t;
|
|
30
|
+
}
|
|
31
|
+
function h(e) {
|
|
32
|
+
let t = 0;
|
|
33
|
+
for (;;) {
|
|
34
|
+
let n = e[0]?.[t];
|
|
35
|
+
if (!n || e.some((e) => !e[t] || !m(e[t].fiber, n.fiber))) return t;
|
|
36
|
+
t++;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function g(e, t) {
|
|
40
|
+
for (let n = 0; n < e.length && n < t.length; n++) if (e[n] !== t[n]) return e[n] - t[n];
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
function _(e) {
|
|
44
|
+
if (e.length <= 1) return e.map((e) => e.item);
|
|
45
|
+
let t = h(e.map((e) => e.path)), n = e.map((e) => e.path.slice(t)), r = /* @__PURE__ */ new Map();
|
|
46
|
+
for (let e of n) for (let { root: t, fiber: n } of e) {
|
|
47
|
+
let e = r.get(t);
|
|
48
|
+
e || r.set(t, e = /* @__PURE__ */ new Set()), e.add(n);
|
|
49
|
+
}
|
|
50
|
+
let i = /* @__PURE__ */ new Map();
|
|
51
|
+
for (let [e, t] of r) p(e, t, i);
|
|
52
|
+
return e.map((e, t) => ({
|
|
53
|
+
...e,
|
|
54
|
+
key: n[t].length ? n[t].map((e) => i.get(e.fiber) ?? Infinity) : [Infinity]
|
|
55
|
+
})).sort((e, t) => g(e.key, t.key) || e.order - t.order).map((e) => e.item);
|
|
56
|
+
}
|
|
57
|
+
function v(e) {
|
|
58
|
+
return _(e.map((e, t) => {
|
|
59
|
+
let n = e.fiber, r = n && f(n);
|
|
60
|
+
return {
|
|
61
|
+
item: e,
|
|
62
|
+
path: n && r ? [{
|
|
63
|
+
fiber: n,
|
|
64
|
+
root: r
|
|
65
|
+
}] : [],
|
|
66
|
+
order: t
|
|
67
|
+
};
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
//#endregion
|
|
71
|
+
//#region src/fiber-anchor.tsx
|
|
72
|
+
var y = class extends n {
|
|
73
|
+
static contextType = u;
|
|
74
|
+
value = null;
|
|
75
|
+
render() {
|
|
76
|
+
return (!this.value || this.value.parent !== this.context) && (this.value = {
|
|
77
|
+
fiber: d(this),
|
|
78
|
+
parent: this.context
|
|
79
|
+
}), /* @__PURE__ */ l(u, {
|
|
80
|
+
value: this.value,
|
|
81
|
+
children: this.props.children
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
function b(t) {
|
|
86
|
+
let n = e(2), r;
|
|
87
|
+
return n[0] === t ? r = n[1] : (r = /* @__PURE__ */ l(y, { ...t }), n[0] = t, n[1] = r), r;
|
|
88
|
+
}
|
|
89
|
+
//#endregion
|
|
90
|
+
//#region src/env.ts
|
|
91
|
+
var x = process.env.NODE_ENV !== "production", S = [], C = () => S, w = "Multiple tunnel Outs mounted; each renders a copy.", T = "Tunnel Ins span React roots in undefined order. Wrap nested React roots in FiberAnchor; nested roots must bridge context.";
|
|
92
|
+
function E() {
|
|
93
|
+
let e = /* @__PURE__ */ new Map(), i = null, p = /* @__PURE__ */ new Set(), h = 0, g = 0, v = !1;
|
|
94
|
+
function y(e) {
|
|
95
|
+
return p.add(e), () => void p.delete(e);
|
|
96
|
+
}
|
|
97
|
+
function b() {
|
|
98
|
+
return i ??= [...e.values()];
|
|
99
|
+
}
|
|
100
|
+
function S(e, t) {
|
|
101
|
+
let n = [], r = f(e);
|
|
102
|
+
r && n.push({
|
|
103
|
+
fiber: e,
|
|
104
|
+
root: r
|
|
105
|
+
});
|
|
106
|
+
for (let e = t; e; e = e.parent) {
|
|
107
|
+
let t = f(e.fiber);
|
|
108
|
+
t && t !== r && (n.unshift({
|
|
109
|
+
fiber: e.fiber,
|
|
110
|
+
root: t
|
|
111
|
+
}), r = t);
|
|
112
|
+
}
|
|
113
|
+
return n;
|
|
114
|
+
}
|
|
115
|
+
function E() {
|
|
116
|
+
i = null;
|
|
117
|
+
for (let e of p) e();
|
|
118
|
+
}
|
|
119
|
+
function D(t, n, r, i) {
|
|
120
|
+
let a = S(r, i);
|
|
121
|
+
x && !v && !e.has(t) && O(a), e.set(t, {
|
|
122
|
+
id: t,
|
|
123
|
+
path: a,
|
|
124
|
+
children: n
|
|
125
|
+
}), E();
|
|
126
|
+
}
|
|
127
|
+
function O(n) {
|
|
128
|
+
for (let r of e.values()) for (let e = 0; e < n.length && e < r.path.length; e++) if (!m(n[e].fiber, r.path[e].fiber)) {
|
|
129
|
+
if (n[e].root === r.path[e].root) break;
|
|
130
|
+
v = !0, console.warn(T + (t.captureOwnerStack?.() ?? ""));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function k(t) {
|
|
135
|
+
e.delete(t), E();
|
|
136
|
+
}
|
|
137
|
+
function A() {
|
|
138
|
+
E();
|
|
139
|
+
}
|
|
140
|
+
class j extends n {
|
|
141
|
+
static contextType = u;
|
|
142
|
+
id = h++;
|
|
143
|
+
anchor = null;
|
|
144
|
+
register() {
|
|
145
|
+
this.anchor = this.context, D(this.id, this.props.children, d(this), this.anchor);
|
|
146
|
+
}
|
|
147
|
+
componentDidMount() {
|
|
148
|
+
this.register();
|
|
149
|
+
}
|
|
150
|
+
componentDidUpdate(e) {
|
|
151
|
+
(e.children !== this.props.children || this.anchor !== this.context) && this.register();
|
|
152
|
+
}
|
|
153
|
+
componentWillUnmount() {
|
|
154
|
+
k(this.id);
|
|
155
|
+
}
|
|
156
|
+
render() {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function M() {
|
|
161
|
+
let e = s(y, b, C), n = o(!1);
|
|
162
|
+
a(() => {
|
|
163
|
+
if (x) return g++, g > 1 && !n.current && (n.current = !0, console.error(w + (t.captureOwnerStack?.() ?? ""))), () => void g--;
|
|
164
|
+
}, []);
|
|
165
|
+
let i = _(e.map((e) => ({
|
|
166
|
+
item: e,
|
|
167
|
+
path: e.path,
|
|
168
|
+
order: e.id
|
|
169
|
+
})));
|
|
170
|
+
return /* @__PURE__ */ l(c, { children: i.map((e) => /* @__PURE__ */ l(r, { children: e.children }, e.id)) });
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
In: j,
|
|
174
|
+
Out: M,
|
|
175
|
+
refresh: A
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
//#endregion
|
|
179
|
+
export { b as FiberAnchor, E as fiberTunnel, v as sortByFiberOrder };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { ComponentType } from 'react';
|
|
2
|
+
import { JSX } from 'react';
|
|
3
|
+
import { ReactNode } from 'react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Places a nested React root's tunnel content at this point in tree order.
|
|
7
|
+
* One nested root per anchor; it must bridge context (r3f's `<Canvas>`
|
|
8
|
+
* does; otherwise use its-fine's `useContextBridge`).
|
|
9
|
+
*
|
|
10
|
+
* Moving the anchor needs the tunnel's `refresh()`. Hiding it (Suspense,
|
|
11
|
+
* Activity) keeps the root's content in the Out.
|
|
12
|
+
*/
|
|
13
|
+
export declare function FiberAnchor(props: FiberAnchorProps): JSX.Element;
|
|
14
|
+
|
|
15
|
+
/** Props of {@link FiberAnchor}. */
|
|
16
|
+
export declare interface FiberAnchorProps {
|
|
17
|
+
/** Host of a nested React root, e.g. an r3f `<Canvas>`. */
|
|
18
|
+
children: ReactNode;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A tunnel created by {@link fiberTunnel}. */
|
|
22
|
+
export declare interface FiberTunnel {
|
|
23
|
+
/** Sends its children to the Out, one commit after mounting. */
|
|
24
|
+
In: ComponentType<FiberTunnelInProps>;
|
|
25
|
+
/** Renders the content. Mount one per tunnel: each Out renders a copy. */
|
|
26
|
+
Out: () => ReactNode;
|
|
27
|
+
/** Re-reads order from the fiber tree. Call after reordering Ins or
|
|
28
|
+
* FiberAnchors. */
|
|
29
|
+
refresh: () => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Creates a tunnel: content from many Ins renders at one Out, in tree order. */
|
|
33
|
+
export declare function fiberTunnel(): FiberTunnel;
|
|
34
|
+
|
|
35
|
+
/** Props of {@link FiberTunnel.In}. */
|
|
36
|
+
export declare interface FiberTunnelInProps {
|
|
37
|
+
/** Rendered at the Out. */
|
|
38
|
+
children: ReactNode;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Sorts items by React tree position. Items must share a React root.
|
|
43
|
+
* Unresolvable items keep input order, after the sorted ones.
|
|
44
|
+
*/
|
|
45
|
+
export declare function sortByFiberOrder<T extends {
|
|
46
|
+
/** The item's React fiber, e.g. from its-fine's `useFiber`. */
|
|
47
|
+
fiber?: object | null;
|
|
48
|
+
}>(items: readonly T[]): T[];
|
|
49
|
+
|
|
50
|
+
export { }
|
package/package.json
CHANGED
|
@@ -1,6 +1,86 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fiber-tunnel",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "React tunnel that renders content in fiber-tree order",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Mara Kim <mara@astralarium.net>",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/astralarium/fiber-tunnel.git"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"react",
|
|
14
|
+
"tunnel",
|
|
15
|
+
"portal",
|
|
16
|
+
"fiber",
|
|
17
|
+
"react-three-fiber",
|
|
18
|
+
"pixi",
|
|
19
|
+
"tunnel-rat"
|
|
20
|
+
],
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/astralarium/fiber-tunnel/issues"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://astralarium.github.io/fiber-tunnel",
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"dist"
|
|
30
|
+
],
|
|
31
|
+
"sideEffects": false,
|
|
32
|
+
"main": "./dist/fiber-tunnel.js",
|
|
33
|
+
"types": "./dist/index.d.ts",
|
|
34
|
+
"exports": {
|
|
35
|
+
".": {
|
|
36
|
+
"types": "./dist/index.d.ts",
|
|
37
|
+
"import": "./dist/fiber-tunnel.js",
|
|
38
|
+
"default": "./dist/fiber-tunnel.js"
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"react": "^19.2.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@babel/core": "^7.29.0",
|
|
46
|
+
"@eslint/js": "^9.39.2",
|
|
47
|
+
"@microsoft/api-extractor": "^7.59.4",
|
|
48
|
+
"@rolldown/plugin-babel": "^0.2.3",
|
|
49
|
+
"@testing-library/dom": "^10.4.0",
|
|
50
|
+
"@testing-library/jest-dom": "^6.6.3",
|
|
51
|
+
"@testing-library/react": "^16.3.0",
|
|
52
|
+
"@types/babel__core": "^7.20.5",
|
|
53
|
+
"@types/node": "^25.0.9",
|
|
54
|
+
"@types/react": "^19.2.9",
|
|
55
|
+
"@types/react-dom": "^19.2.3",
|
|
56
|
+
"@vitejs/plugin-react": "^6.0.2",
|
|
57
|
+
"babel-plugin-react-compiler": "^1.0.0",
|
|
58
|
+
"eslint": "^9.39.2",
|
|
59
|
+
"eslint-config-prettier": "^10.1.8",
|
|
60
|
+
"eslint-plugin-prettier": "^5.5.5",
|
|
61
|
+
"eslint-plugin-react-hooks": "^7.0.1",
|
|
62
|
+
"eslint-plugin-react-refresh": "^0.4.26",
|
|
63
|
+
"eslint-plugin-simple-import-sort": "^12.1.1",
|
|
64
|
+
"globals": "^17.0.0",
|
|
65
|
+
"its-fine": "^2.0.0",
|
|
66
|
+
"jsdom": "^29.0.0",
|
|
67
|
+
"prettier": "^3.8.1",
|
|
68
|
+
"react": "^19.2.3",
|
|
69
|
+
"react-dom": "^19.2.3",
|
|
70
|
+
"typedoc": "^0.28.16",
|
|
71
|
+
"typedoc-rhineai-theme": "^1.2.0",
|
|
72
|
+
"typescript": "~5.9.3",
|
|
73
|
+
"typescript-eslint": "^8.53.1",
|
|
74
|
+
"vite": "^8.0.16",
|
|
75
|
+
"vite-plugin-dts": "^4.5.4",
|
|
76
|
+
"vitest": "^4.0.0"
|
|
77
|
+
},
|
|
78
|
+
"scripts": {
|
|
79
|
+
"build": "tsc -b && eslint . && vite build && pnpm pack --out pack/%s-%v.tgz",
|
|
80
|
+
"build:docs": "typedoc",
|
|
81
|
+
"lint": "eslint .",
|
|
82
|
+
"check-types": "tsc -b",
|
|
83
|
+
"test": "vitest run",
|
|
84
|
+
"test:watch": "vitest"
|
|
85
|
+
}
|
|
6
86
|
}
|