Skip to content

Commit 8d8cf22

Browse files
committed
feat: Signal based hooks()
Closes #50
1 parent d43a110 commit 8d8cf22

File tree

13 files changed

+235
-197
lines changed

13 files changed

+235
-197
lines changed

docs/hooks.md

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
# hooks
22

33
Using React hooks inside Svelte components.
4-
Because React doesn't have a synchronous render (by-design), the initial value of the store will be `undefined`.
54

65
The `hooks()` function uses Svelte lifecycle functions, so you can only call the function during component initialization.
76

@@ -11,29 +10,28 @@ The `hooks()` function uses Svelte lifecycle functions, so you can only call the
1110
<script lang="ts">
1211
import { hooks } from "svelte-preprocess-react";
1312
14-
const store = hooks(() => useState(0));
13+
const [count, setCount] = $derived.by(hooks(() => useState(0)));
1514
</script>
1615
17-
{#if $store}
18-
{@const [count, setCount] = $store}
19-
<h2>Count: {count}</h2>
20-
<button onclick={() => setCount(count + 1)}>+</button>
21-
{/if}
16+
<h2>Count: {count}</h2>
17+
<button onclick={() => setCount(count + 1)}>+</button>
2218
```
2319

24-
What is returned from the hook becomes the value of the store, so to calling multiple hooks is fine, but [the rules of hooks](https://reactjs.org/docs/hooks-rules.html) still apply.
20+
hooks() returns a function, when that function retrieves the reactive state, by using $derived.by, the updates from React are applied. Inside the callback you can call multiple hooks, but [the rules of hooks](https://reactjs.org/docs/hooks-rules.html) still apply.
2521

2622
```ts
27-
const store = hooks(() => {
28-
const multiplier = useContext(MultiplierContext);
29-
const [count, setCount] = useState(0);
30-
return {
31-
multiply: () => setCount(count * multiplier),
32-
reset: () => setCount(0),
33-
};
34-
});
23+
const actions = $derived.by(
24+
hooks(() => {
25+
const multiplier = useContext(MultiplierContext);
26+
const [count, setCount] = useState(0);
27+
return {
28+
multiply: () => setCount(count * multiplier),
29+
reset: () => setCount(0),
30+
};
31+
}),
32+
);
3533

3634
function onReset() {
37-
$store?.reset();
35+
actions.reset();
3836
}
3937
```

src/lib/global.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ declare global {
1919
[K in keyof T]: Sveltified<T[K]> & StaticPropComponents;
2020
} & IntrinsicElementComponents;
2121

22-
function hooks<T>(callback: () => T): Readable<T | undefined>;
22+
function hooks<T>(callback: () => T): (() => T) & Readable<T>;
2323

2424
const react: IntrinsicElementComponents & {
2525
[component: string]: Component & StaticPropComponents;

src/lib/hooks.svelte.ts

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import * as React from "react";
2+
import { getContext, onDestroy } from "svelte";
3+
import { type Readable, writable } from "svelte/store";
4+
import type { ReactDependencies, TreeNode } from "./internal/types";
5+
import type { Root } from "react-dom/client";
6+
7+
type Dependencies = Omit<ReactDependencies, "createPortal">;
8+
9+
export default function hooks<T>(
10+
callback: () => T,
11+
dependencies?: Dependencies,
12+
): (() => T) & Readable<T> {
13+
let state = $state<T>();
14+
const store = writable<T>();
15+
16+
const parent = getContext<TreeNode | undefined>("ReactWrapper");
17+
18+
function Hook() {
19+
state = callback();
20+
store.set(state);
21+
return null;
22+
}
23+
24+
if (!dependencies || !dependencies.ReactDOM || !dependencies.flushSync) {
25+
throw new Error(
26+
"{ ReactDOM, flushSync } are not injected, check svelte.config.js for: `preprocess: [preprocessReact()],`",
27+
);
28+
}
29+
if (parent) {
30+
const hook = { Hook, key: autoKey(parent) };
31+
parent.hooks.push(hook);
32+
33+
dependencies.flushSync(() => {
34+
parent.rerender?.("hooks");
35+
});
36+
37+
onDestroy(() => {
38+
const index = parent.hooks.findIndex((h) => h === hook);
39+
if (index !== -1) {
40+
parent.hooks.splice(index, 1);
41+
}
42+
});
43+
} else {
44+
onDestroy(standalone(Hook, dependencies));
45+
}
46+
let subscribe = (fn: (value: T | undefined) => void) => {
47+
console.warn(
48+
"Using a hooks() as store is deprecated, instead use $derived.by: `let [state, setState] = $derived.by(hooks(() => useState(1)));`",
49+
);
50+
subscribe = store.subscribe;
51+
return store.subscribe(fn);
52+
};
53+
function signal() {
54+
return state;
55+
}
56+
signal.subscribe = (fn: (value: T | undefined) => void) => {
57+
return subscribe(fn);
58+
};
59+
return signal as any;
60+
}
61+
62+
function standalone(Hook: React.FC, dependencies: Dependencies) {
63+
const { renderToString, ReactDOM, flushSync } = dependencies;
64+
if (typeof document === "undefined") {
65+
if (!renderToString) {
66+
throw new Error("renderToString parameter is required for SSR");
67+
}
68+
renderToString(React.createElement(Hook));
69+
return () => {};
70+
}
71+
const el = document.createElement("react-hooks");
72+
let root: Root | undefined;
73+
if ("createRoot" in ReactDOM) {
74+
root = ReactDOM.createRoot?.(el);
75+
flushSync(() => {
76+
root?.render(React.createElement(Hook));
77+
});
78+
} else {
79+
ReactDOM.render(React.createElement(Hook), el);
80+
}
81+
return () => {
82+
if (root) {
83+
root.unmount();
84+
} else if ("unmountComponentAtNode" in ReactDOM) {
85+
ReactDOM.unmountComponentAtNode(el);
86+
}
87+
};
88+
}
89+
90+
const keys = new WeakMap();
91+
/**
92+
* Get incrementing number per node.
93+
*/
94+
function autoKey(node: TreeNode | undefined) {
95+
if (!node) {
96+
return -1;
97+
}
98+
let key: number | undefined = keys.get(node);
99+
if (key === undefined) {
100+
key = 0;
101+
} else {
102+
key += 1;
103+
}
104+
keys.set(node, key);
105+
return key;
106+
}

src/lib/hooks.ts

Lines changed: 0 additions & 95 deletions
This file was deleted.

src/lib/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/// <reference types="./global" />
22

3-
export { default as hooks } from "./hooks.js";
3+
export { default as hooks } from "./hooks.svelte.js";
44
export { default as reactify } from "./reactify.js";
55
export { default as sveltify } from "./sveltify.svelte.js";
66
export { default as used } from "./used.js";

src/lib/internal/Bridge.svelte.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,21 @@ import portalTag from "svelte-preprocess-react/internal/portalTag.js";
66

77
type BridgeProps = {
88
node: TreeNode;
9-
createPortal?: ReactDependencies["createPortal"];
9+
createPortal: ReactDependencies["createPortal"];
10+
source?: "hooks";
1011
};
11-
const Bridge: React.FC<BridgeProps> = ({ node, createPortal }) => {
12+
const Bridge: React.FC<BridgeProps> = ({ node, createPortal, source }) => {
1213
const fresh = useRef(false);
13-
const [result, setResult] = useState<React.ReactNode>(() =>
14-
renderBridge(node, createPortal, true),
15-
);
14+
const mounted = useRef(false);
15+
const [result, setResult] = useState<React.ReactNode>(() => {
16+
return renderBridge(node, createPortal, mounted, source);
17+
});
1618
useEffect(
1719
() =>
1820
$effect.root(() => {
1921
$effect(() => {
2022
fresh.current = true;
21-
setResult(renderBridge(node, createPortal, false));
23+
setResult(renderBridge(node, createPortal, mounted, source));
2224
});
2325
}),
2426
[],
@@ -27,13 +29,14 @@ const Bridge: React.FC<BridgeProps> = ({ node, createPortal }) => {
2729
fresh.current = false;
2830
return result;
2931
}
30-
return renderBridge(node, createPortal, false);
32+
return renderBridge(node, createPortal, mounted, source);
3133
};
3234

3335
function renderBridge(
3436
node: TreeNode,
3537
createPortal: BridgeProps["createPortal"],
36-
initialRender: boolean,
38+
mounted: { current: boolean },
39+
source?: "hooks",
3740
) {
3841
let { children } = node.props;
3942
const props = { ...node.props.reactProps };
@@ -74,13 +77,13 @@ function renderBridge(
7477
? createElement(node.reactComponent, props)
7578
: createElement(node.reactComponent, props, children),
7679
);
77-
if (portalTarget && createPortal) {
78-
if (initialRender) {
80+
if (portalTarget) {
81+
if (source !== "hooks" && mounted.current === false) {
7982
portalTarget.innerHTML = ""; // Remove injected SSR content
83+
mounted.current = true;
8084
}
8185
return createPortal(vdom, portalTarget);
8286
}
83-
8487
return createElement(
8588
portalTag("react", "portal", "source", node.key),
8689
{ style: { display: "none" } },

src/lib/internal/types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export type TreeNode = SvelteInit & {
5959
key: string;
6060
autoKey: number;
6161
nodes: TreeNode[];
62-
rerender?: () => void;
62+
rerender?: (source?: "hooks") => void;
6363
unroot?: () => void;
6464
};
6565

@@ -108,12 +108,14 @@ export type ReactDependencies = {
108108
}
109109
| {
110110
render(component: React.ReactNode, container: Element): void; // React 17 and below
111+
unmountComponentAtNode(container: Element): void;
111112
};
112113
createPortal: (
113114
children: React.ReactNode,
114115
container: Element | DocumentFragment,
115116
key?: null | string,
116117
) => React.ReactPortal;
118+
flushSync: (cb: () => void) => void;
117119
renderToString?: typeof ReactDOMServer.renderToString;
118120
};
119121

0 commit comments

Comments
 (0)