immerser-react 1.0.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.
@@ -0,0 +1,293 @@
1
+ import { jsx } from "react/jsx-runtime";
2
+ import ImmerserController, { InteractiveStyles, CroppedFullAbsoluteStyles, NotInteractiveStyles } from "immerser";
3
+ import { createContext, useRef, useState, useCallback, useLayoutEffect, useEffect, useMemo, useContext, Fragment, Children, isValidElement, cloneElement } from "react";
4
+ import classNames from "classnames";
5
+ const ImmerserConfigContext = createContext(null);
6
+ const ImmerserContext = createContext(null);
7
+ const ImmerserSynchroContext = createContext({
8
+ activeSynchroId: null,
9
+ setActiveSynchroId: () => {
10
+ }
11
+ });
12
+ const useImmerserRegistry = () => {
13
+ const maskInnerNodesRef = useRef(/* @__PURE__ */ new Map());
14
+ const [layerIds, setLayerIds] = useState([]);
15
+ const [, setReadyVersion] = useState(0);
16
+ const isReady = layerIds.length > 0 && maskInnerNodesRef.current.size === layerIds.length && layerIds.every((layerId) => maskInnerNodesRef.current.has(layerId));
17
+ const registerLayer = useCallback((id) => {
18
+ setLayerIds((currentLayerIds) => {
19
+ if (currentLayerIds.includes(id)) {
20
+ return currentLayerIds;
21
+ }
22
+ return [...currentLayerIds, id];
23
+ });
24
+ }, []);
25
+ const unregisterLayer = useCallback((id) => {
26
+ setLayerIds((currentLayerIds) => currentLayerIds.filter((layerId) => layerId !== id));
27
+ }, []);
28
+ const registerMaskInner = useCallback((id, node) => {
29
+ if (node) {
30
+ if (maskInnerNodesRef.current.get(id) === node) {
31
+ return;
32
+ }
33
+ maskInnerNodesRef.current.set(id, node);
34
+ } else {
35
+ if (!maskInnerNodesRef.current.has(id)) {
36
+ return;
37
+ }
38
+ maskInnerNodesRef.current.delete(id);
39
+ }
40
+ setReadyVersion((version) => version + 1);
41
+ }, []);
42
+ return {
43
+ isReady,
44
+ layerIds,
45
+ maskInnerNodesRef,
46
+ registerLayer,
47
+ unregisterLayer,
48
+ registerMaskInner
49
+ };
50
+ };
51
+ const ImmerserProvider = ({ children, on, solidClassnamesByLayerId, selectorRoot, ...options }) => {
52
+ const [activeIndex, setActiveIndex] = useState(-1);
53
+ const [activeSynchroId, setActiveSynchroId] = useState(null);
54
+ const [rendererRootNode, setRendererRootNode] = useState(null);
55
+ const activeIndexRef = useRef(activeIndex);
56
+ const controllerRef = useRef(null);
57
+ const latestControllerOptionsRef = useRef(options);
58
+ const immerserRegistry = useImmerserRegistry();
59
+ const layerIds = immerserRegistry.layerIds;
60
+ const { debug, fromViewportWidth, updateLocationHash, pagerThreshold, scrollAdjustDelay, scrollAdjustThreshold } = options;
61
+ const latestLayerIdsRef = useRef(layerIds);
62
+ latestControllerOptionsRef.current = options;
63
+ latestLayerIdsRef.current = layerIds;
64
+ function syncState(nextController) {
65
+ if (nextController.activeIndex === -1 && nextController.layerProgressArray.length === 0 && latestLayerIdsRef.current.length > 0 || activeIndexRef.current === nextController.activeIndex) {
66
+ return;
67
+ }
68
+ activeIndexRef.current = nextController.activeIndex;
69
+ setActiveIndex(nextController.activeIndex);
70
+ }
71
+ useLayoutEffect(() => {
72
+ return () => {
73
+ const controller = controllerRef.current;
74
+ if (!controller) {
75
+ return;
76
+ }
77
+ controller.destroy();
78
+ controllerRef.current = null;
79
+ if (activeIndexRef.current !== -1) {
80
+ activeIndexRef.current = -1;
81
+ setActiveIndex(-1);
82
+ }
83
+ };
84
+ }, [rendererRootNode, selectorRoot]);
85
+ useLayoutEffect(() => {
86
+ if (controllerRef.current || !rendererRootNode || !immerserRegistry.isReady) {
87
+ return;
88
+ }
89
+ const controller = new ImmerserController({
90
+ ...latestControllerOptionsRef.current,
91
+ // React renders masks and solid clones, so the core must only measure and drive them.
92
+ hasExternalRenderer: true,
93
+ on,
94
+ selectorRoot: selectorRoot ?? rendererRootNode.parentNode ?? document
95
+ });
96
+ controllerRef.current = controller;
97
+ controller.on("stateChange", syncState);
98
+ syncState(controller);
99
+ }, [rendererRootNode, selectorRoot, immerserRegistry.isReady]);
100
+ useLayoutEffect(() => {
101
+ var _a;
102
+ if (!immerserRegistry.isReady) {
103
+ return;
104
+ }
105
+ (_a = controllerRef.current) == null ? void 0 : _a.render();
106
+ }, [children, layerIds, immerserRegistry.isReady, solidClassnamesByLayerId]);
107
+ useEffect(() => {
108
+ var _a;
109
+ const nextOptions = latestControllerOptionsRef.current;
110
+ (_a = controllerRef.current) == null ? void 0 : _a.updateOptions(nextOptions);
111
+ }, [debug, fromViewportWidth, updateLocationHash, pagerThreshold, scrollAdjustDelay, scrollAdjustThreshold]);
112
+ const configContextValue = useMemo(
113
+ () => ({
114
+ layerIds,
115
+ registerLayer: immerserRegistry.registerLayer,
116
+ registerMaskInner: immerserRegistry.registerMaskInner,
117
+ setRendererRootNode,
118
+ solidClassnamesByLayerId,
119
+ unregisterLayer: immerserRegistry.unregisterLayer
120
+ }),
121
+ [
122
+ layerIds,
123
+ immerserRegistry.registerLayer,
124
+ immerserRegistry.registerMaskInner,
125
+ immerserRegistry.unregisterLayer,
126
+ solidClassnamesByLayerId
127
+ ]
128
+ );
129
+ const synchroContextValue = useMemo(
130
+ () => ({
131
+ activeSynchroId,
132
+ setActiveSynchroId
133
+ }),
134
+ [activeSynchroId]
135
+ );
136
+ return /* @__PURE__ */ jsx(ImmerserConfigContext.Provider, { value: configContextValue, children: /* @__PURE__ */ jsx(ImmerserContext.Provider, { value: activeIndex, children: /* @__PURE__ */ jsx(ImmerserSynchroContext.Provider, { value: synchroContextValue, children }) }) });
137
+ };
138
+ ImmerserProvider.displayName = "ImmerserProvider";
139
+ const throwOutsideProviderError = (componentName) => {
140
+ const message = `${componentName} must be used inside <ImmerserProvider>.`;
141
+ throw new Error(message);
142
+ };
143
+ const useImmerserConfigContext = (componentName) => {
144
+ const context = useContext(ImmerserConfigContext);
145
+ if (!context) {
146
+ throwOutsideProviderError(componentName);
147
+ }
148
+ return context;
149
+ };
150
+ const ImmerserSolid = ({
151
+ as,
152
+ children,
153
+ className,
154
+ name,
155
+ style: _style,
156
+ ...rest
157
+ }) => {
158
+ useImmerserConfigContext("ImmerserSolid");
159
+ const Component = as ?? "div";
160
+ return /* @__PURE__ */ jsx(Component, { ...rest, className, "data-immerser-solid": name, style: InteractiveStyles, children });
161
+ };
162
+ ImmerserSolid.displayName = "ImmerserSolid";
163
+ const ImmerserSynchroLink = ({
164
+ className,
165
+ hoverClassName,
166
+ onMouseEnter,
167
+ onMouseLeave,
168
+ synchroId,
169
+ ...rest
170
+ }) => {
171
+ const { activeSynchroId, setActiveSynchroId } = useContext(ImmerserSynchroContext);
172
+ function handleMouseEnter(event) {
173
+ setActiveSynchroId(synchroId);
174
+ onMouseEnter == null ? void 0 : onMouseEnter(event);
175
+ }
176
+ function handleMouseLeave(event) {
177
+ setActiveSynchroId((currentSynchroId) => currentSynchroId === synchroId ? null : currentSynchroId);
178
+ onMouseLeave == null ? void 0 : onMouseLeave(event);
179
+ }
180
+ return /* @__PURE__ */ jsx(
181
+ "a",
182
+ {
183
+ ...rest,
184
+ className: classNames(className, {
185
+ [hoverClassName]: activeSynchroId === synchroId
186
+ }),
187
+ onMouseEnter: handleMouseEnter,
188
+ onMouseLeave: handleMouseLeave
189
+ }
190
+ );
191
+ };
192
+ ImmerserSynchroLink.displayName = "ImmerserSynchroLink";
193
+ const useImmerserContext = (componentName) => {
194
+ const context = useContext(ImmerserContext);
195
+ if (context === null) {
196
+ throwOutsideProviderError(componentName);
197
+ }
198
+ return context;
199
+ };
200
+ const ImmerserPager = ({
201
+ activeClassName,
202
+ className,
203
+ linkClassName,
204
+ as = "nav",
205
+ hoverClassName = "_hover",
206
+ renderLink,
207
+ ...rest
208
+ }) => {
209
+ const { layerIds } = useImmerserConfigContext("ImmerserPager");
210
+ const activeIndex = useImmerserContext("ImmerserPager");
211
+ return /* @__PURE__ */ jsx(ImmerserSolid, { ...rest, as, className, "data-immerser-pager": "", name: "pager", children: layerIds.map((layerId, layerIndex) => {
212
+ const isActive = layerIndex === activeIndex;
213
+ if (renderLink) {
214
+ return /* @__PURE__ */ jsx(Fragment, { children: renderLink({ isActive, layerId, layerIndex }) }, layerId);
215
+ }
216
+ return /* @__PURE__ */ jsx(
217
+ ImmerserSynchroLink,
218
+ {
219
+ className: classNames(linkClassName, {
220
+ [activeClassName]: isActive
221
+ }),
222
+ href: `#${layerId}`,
223
+ hoverClassName,
224
+ synchroId: `pager-${layerIndex}`
225
+ },
226
+ layerId
227
+ );
228
+ }) });
229
+ };
230
+ ImmerserPager.displayName = "ImmerserPager";
231
+ const renderSolidsForLayer = (children, solidClassnames = {}) => Children.map(children, (child) => {
232
+ if (child === null || child === void 0 || child === false) {
233
+ return child;
234
+ }
235
+ if (!isValidElement(child) || child.type !== ImmerserSolid && child.type !== ImmerserPager) {
236
+ throw new Error("ImmerserRoot accepts only ImmerserSolid or ImmerserPager as direct children.");
237
+ }
238
+ const name = child.props.name ?? "pager";
239
+ return cloneElement(child, {
240
+ className: classNames(child.props.className, solidClassnames[name])
241
+ });
242
+ });
243
+ const maskStyle = {
244
+ ...CroppedFullAbsoluteStyles
245
+ };
246
+ const ImmerserRoot = ({ children, style: _style, ...rest }) => {
247
+ const { layerIds, registerMaskInner, setRendererRootNode, solidClassnamesByLayerId } = useImmerserConfigContext("ImmerserRoot");
248
+ const rootRef = useRef(null);
249
+ useLayoutEffect(() => {
250
+ setRendererRootNode(rootRef.current);
251
+ return () => {
252
+ setRendererRootNode(null);
253
+ };
254
+ }, [setRendererRootNode]);
255
+ return /* @__PURE__ */ jsx("div", { ref: rootRef, ...rest, "data-immerser": "", style: NotInteractiveStyles, children: layerIds.map((layerId, layerIndex) => /* @__PURE__ */ jsx(
256
+ "div",
257
+ {
258
+ "aria-hidden": layerIndex === 0 ? void 0 : true,
259
+ "data-immerser-layer-id": layerId,
260
+ "data-immerser-mask": "",
261
+ style: maskStyle,
262
+ children: /* @__PURE__ */ jsx("div", { ref: (node) => registerMaskInner(layerId, node), "data-immerser-mask-inner": "", style: maskStyle, children: renderSolidsForLayer(children, solidClassnamesByLayerId[layerId]) })
263
+ },
264
+ layerId
265
+ )) });
266
+ };
267
+ ImmerserRoot.displayName = "ImmerserRoot";
268
+ const ImmerserLayer = ({
269
+ as,
270
+ children,
271
+ id,
272
+ style: _style,
273
+ ...rest
274
+ }) => {
275
+ const { registerLayer, unregisterLayer } = useImmerserConfigContext("ImmerserLayer");
276
+ const Component = as ?? "div";
277
+ useLayoutEffect(() => {
278
+ registerLayer(id);
279
+ return () => {
280
+ unregisterLayer(id);
281
+ };
282
+ }, [id, registerLayer, unregisterLayer]);
283
+ return /* @__PURE__ */ jsx(Component, { id, ...rest, "data-immerser-layer": true, children });
284
+ };
285
+ ImmerserLayer.displayName = "ImmerserLayer";
286
+ export {
287
+ ImmerserLayer,
288
+ ImmerserPager,
289
+ ImmerserProvider,
290
+ ImmerserRoot,
291
+ ImmerserSolid,
292
+ ImmerserSynchroLink
293
+ };
package/package.json ADDED
@@ -0,0 +1,73 @@
1
+ {
2
+ "name": "immerser-react",
3
+ "version": "1.0.0",
4
+ "description": "React adapter for declaring Immerser markup with components.",
5
+ "type": "module",
6
+ "main": "./dist/immerser-react.cjs",
7
+ "module": "./dist/immerser-react.js",
8
+ "types": "./dist/immerser-react.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/immerser-react.d.ts",
12
+ "import": "./dist/immerser-react.js",
13
+ "require": "./dist/immerser-react.cjs",
14
+ "default": "./dist/immerser-react.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "src/*.ts",
20
+ "src/*.tsx",
21
+ "src/context",
22
+ "src/utils",
23
+ "README.md"
24
+ ],
25
+ "keywords": [
26
+ "immerser",
27
+ "react",
28
+ "scroll",
29
+ "fixed",
30
+ "sticky"
31
+ ],
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+ssh://git@github.com/dubaua/immerser-react.git"
35
+ },
36
+ "author": {
37
+ "name": "Vladimir Lysov",
38
+ "email": "dubaua@gmail.com"
39
+ },
40
+ "license": "MIT",
41
+ "homepage": "https://dubaua.github.io/immerser-react/",
42
+ "sideEffects": false,
43
+ "scripts": {
44
+ "predev": "npm run build:lib && npm run build:types",
45
+ "dev": "vite --force --config src/example/vite.config.ts",
46
+ "build:readme": "node scripts/build-readme.js",
47
+ "build:lib": "vite build",
48
+ "build:types": "tsc --project tsconfig.types.json && api-extractor run --local",
49
+ "build:docs": "vite build --config src/example/vite.config.ts",
50
+ "build": "npm run build:readme && npm run build:lib && npm run build:types && npm run build:docs",
51
+ "typecheck": "tsc -b"
52
+ },
53
+ "dependencies": {
54
+ "classnames": "^2.5.1"
55
+ },
56
+ "peerDependencies": {
57
+ "react": ">=18.0.0 <20.0.0",
58
+ "immerser": "^6.1.1"
59
+ },
60
+ "devDependencies": {
61
+ "@microsoft/api-extractor": "^7.55.1",
62
+ "@types/node": "^24.10.1",
63
+ "@types/react": "^19.2.7",
64
+ "@types/react-dom": "^19.2.3",
65
+ "@vitejs/plugin-react": "^4.7.0",
66
+ "immerser": "^6.1.1",
67
+ "react": "^19.2.3",
68
+ "react-dom": "^19.2.3",
69
+ "terser": "^5.48.0",
70
+ "typescript": "^5.4.0",
71
+ "vite": "^5.4.0"
72
+ }
73
+ }
@@ -0,0 +1,49 @@
1
+ import { useLayoutEffect, type ComponentPropsWithoutRef, type ElementType, type ReactNode } from 'react';
2
+
3
+ import type { DeniedStyleProp } from './types';
4
+ import { useImmerserConfigContext } from './context/use-immerser-config-context';
5
+
6
+ type Props<T extends ElementType = 'div'> = {
7
+ /** Element used for the layer; defaults to `div`. */
8
+ as?: T;
9
+ /** Layer content that defines the page section measured by the core controller. */
10
+ children?: ReactNode;
11
+ /** Stable layer id used for hash links, pager links and solid classname lookup.
12
+ * Must match a `solidClassnamesByLayerId` key. */
13
+ id: string;
14
+ } & Omit<ComponentPropsWithoutRef<T>, 'as' | 'children' | 'id' | 'style'> &
15
+ DeniedStyleProp;
16
+
17
+ /**
18
+ * Marks a real section as an immerser layer.
19
+ * The core uses these nodes to calculate layer bounds, progress and active index.
20
+ * Render one layer component for every scroll section that should drive solid class changes.
21
+ *
22
+ * @public
23
+ */
24
+ export const ImmerserLayer = <T extends ElementType = 'div'>({
25
+ as,
26
+ children,
27
+ id,
28
+ style: _style,
29
+ ...rest
30
+ }: Props<T>) => {
31
+ const { registerLayer, unregisterLayer } = useImmerserConfigContext('ImmerserLayer');
32
+ const Component = as ?? 'div';
33
+
34
+ useLayoutEffect(() => {
35
+ registerLayer(id);
36
+
37
+ return () => {
38
+ unregisterLayer(id);
39
+ };
40
+ }, [id, registerLayer, unregisterLayer]);
41
+
42
+ return (
43
+ <Component id={id} {...rest} data-immerser-layer>
44
+ {children}
45
+ </Component>
46
+ );
47
+ };
48
+
49
+ ImmerserLayer.displayName = 'ImmerserLayer';
@@ -0,0 +1,88 @@
1
+ import classNames from 'classnames';
2
+ import { Fragment, type ComponentPropsWithoutRef, type ElementType, type ReactNode } from 'react';
3
+
4
+ import { ImmerserSolid } from './ImmerserSolid';
5
+ import { ImmerserSynchroLink } from './ImmerserSynchroLink';
6
+ import { useImmerserConfigContext } from './context/use-immerser-config-context';
7
+ import { useImmerserContext } from './context/use-immerser-context';
8
+
9
+ type RenderLinkProps = {
10
+ isActive: boolean;
11
+ layerId: string;
12
+ layerIndex: number;
13
+ };
14
+
15
+ type DefaultModeProps = {
16
+ /** Classname applied to the generated link for the currently active layer. */
17
+ activeClassName: string;
18
+ /** Classname applied to each generated pager link. */
19
+ linkClassName: string;
20
+ /** Classname applied to generated link copies while any of them is hovered. */
21
+ hoverClassName?: string;
22
+ renderLink?: never;
23
+ };
24
+
25
+ type CustomRenderModeProps = {
26
+ /** Renders custom content for each configured layer. */
27
+ renderLink: (props: RenderLinkProps) => ReactNode;
28
+ activeClassName?: never;
29
+ linkClassName?: never;
30
+ hoverClassName?: never;
31
+ };
32
+
33
+ type Props<T extends ElementType = 'nav'> = {
34
+ /** Element used for the pager wrapper; defaults to `nav`. */
35
+ as?: T;
36
+ } & (DefaultModeProps | CustomRenderModeProps) &
37
+ Omit<ComponentPropsWithoutRef<T>, 'activeClassName' | 'children' | 'style'>;
38
+
39
+ /**
40
+ * Builds a pager solid inside `ImmerserRoot` from provider layer ids.
41
+ * Renders one link per DOM layer as a solid named `pager`, ordered by `ImmerserLayer` DOM order.
42
+ * Add `pager` classnames to layer configs when the pager needs per-layer visual changes.
43
+ * It mirrors core pager behavior in React so active state comes from context instead of DOM class mutation.
44
+ * In default mode each generated link receives `linkClassName`, `href="#layerId"`,
45
+ * `hoverClassName` with `_hover` as the default, and `synchroId="pager-${layerIndex}"`.
46
+ * Custom render mode receives `isActive`, `layerId` and `layerIndex`
47
+ * and does not add those generated-link props.
48
+ *
49
+ * @public
50
+ */
51
+ export const ImmerserPager = ({
52
+ activeClassName,
53
+ className,
54
+ linkClassName,
55
+ as = 'nav',
56
+ hoverClassName = '_hover',
57
+ renderLink,
58
+ ...rest
59
+ }: Props) => {
60
+ const { layerIds } = useImmerserConfigContext('ImmerserPager');
61
+ const activeIndex = useImmerserContext('ImmerserPager');
62
+
63
+ return (
64
+ <ImmerserSolid {...rest} as={as} className={className} data-immerser-pager="" name="pager">
65
+ {layerIds.map((layerId, layerIndex) => {
66
+ const isActive = layerIndex === activeIndex;
67
+
68
+ if (renderLink) {
69
+ return <Fragment key={layerId}>{renderLink({ isActive, layerId, layerIndex })}</Fragment>;
70
+ }
71
+
72
+ return (
73
+ <ImmerserSynchroLink
74
+ key={layerId}
75
+ className={classNames(linkClassName, {
76
+ [activeClassName]: isActive,
77
+ })}
78
+ href={`#${layerId}`}
79
+ hoverClassName={hoverClassName}
80
+ synchroId={`pager-${layerIndex}`}
81
+ />
82
+ );
83
+ })}
84
+ </ImmerserSolid>
85
+ );
86
+ };
87
+
88
+ ImmerserPager.displayName = 'ImmerserPager';
@@ -0,0 +1,167 @@
1
+ import ImmerserController, { type Options, type RuntimeOptions } from 'immerser';
2
+ import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react';
3
+
4
+ import { ImmerserConfigContext } from './context/immerser-config-context';
5
+ import { ImmerserContext } from './context/immerser-context';
6
+ import { ImmerserSynchroContext } from './context/immerser-synchro-context';
7
+
8
+ import { useImmerserRegistry } from './utils/use-immerser-registry';
9
+
10
+ type Props = {
11
+ /** React tree that declares an immerser root, its absolute solids and scroll layers. */
12
+ children?: ReactNode;
13
+ /**
14
+ * Initial event handlers registered when the core controller is created.
15
+ * Changing this prop does not update the current controller.
16
+ */
17
+ on?: Options['on'];
18
+ /** Parent node used for selector discovery. Changing it recreates the core controller. */
19
+ selectorRoot?: Options['selectorRoot'];
20
+ /**
21
+ * React-only per-layer solid modifiers keyed by layer id.
22
+ * This is intentionally not passed to the core controller, even though the core has a similarly named option.
23
+ * The React adapter uses it to render masked solid clones itself.
24
+ */
25
+ solidClassnamesByLayerId: Options['solidClassnamesByLayerId'];
26
+ } & Partial<RuntimeOptions>;
27
+
28
+ /**
29
+ * Owns the core `Immerser` controller lifecycle and shares its scroll state with React components.
30
+ * Provider props are adapter-specific props plus `Partial<RuntimeOptions>` from `immerser`.
31
+ * `RuntimeOptions` is the source of hot core options accepted by the React adapter.
32
+ * Event handlers passed through `on` are init-only and registered when the controller is created.
33
+ * `selectorRoot` recreates the core controller when changed.
34
+ * Runtime options are forwarded through `updateOptions`.
35
+ * See [core options docs](https://github.com/dubaua/immerser#options).
36
+ * `solidClassnamesByLayerId` keys must match `ImmerserLayer` ids.
37
+ * `solidClassnamesByLayerId` keeps the same shape as the constructor option,
38
+ * but the adapter uses it to render solid copies inside each layer mask and does not forward it as-is.
39
+ * Init-only and adapter-owned core options are not exposed:
40
+ * `autoMount`, `hasExternalScroll`, `hasExternalRenderer`, `pagerLinkActiveClassname`
41
+ * and the core `solidClassnamesByLayerId` contract.
42
+ * This keeps DOM measurement, mask rendering and scroll listeners in one place
43
+ * while the rest of the API stays declarative.
44
+ *
45
+ * @public
46
+ */
47
+ export const ImmerserProvider = ({ children, on, solidClassnamesByLayerId, selectorRoot, ...options }: Props) => {
48
+ const [activeIndex, setActiveIndex] = useState(-1);
49
+ const [activeSynchroId, setActiveSynchroId] = useState<string | null>(null);
50
+ const [rendererRootNode, setRendererRootNode] = useState<HTMLDivElement | null>(null);
51
+
52
+ const activeIndexRef = useRef(activeIndex);
53
+ const controllerRef = useRef<ImmerserController | null>(null);
54
+ const latestControllerOptionsRef = useRef(options);
55
+ const immerserRegistry = useImmerserRegistry();
56
+ const layerIds = immerserRegistry.layerIds;
57
+
58
+ const { debug, fromViewportWidth, updateLocationHash, pagerThreshold, scrollAdjustDelay, scrollAdjustThreshold } =
59
+ options;
60
+
61
+ const latestLayerIdsRef = useRef(layerIds);
62
+
63
+ latestControllerOptionsRef.current = options;
64
+ latestLayerIdsRef.current = layerIds;
65
+
66
+ function syncState(nextController: ImmerserController) {
67
+ if (
68
+ (nextController.activeIndex === -1 &&
69
+ nextController.layerProgressArray.length === 0 &&
70
+ latestLayerIdsRef.current.length > 0) ||
71
+ activeIndexRef.current === nextController.activeIndex
72
+ ) {
73
+ return;
74
+ }
75
+
76
+ activeIndexRef.current = nextController.activeIndex;
77
+ setActiveIndex(nextController.activeIndex);
78
+ }
79
+
80
+ useLayoutEffect(() => {
81
+ return () => {
82
+ const controller = controllerRef.current;
83
+
84
+ if (!controller) {
85
+ return;
86
+ }
87
+
88
+ controller.destroy();
89
+ controllerRef.current = null;
90
+
91
+ if (activeIndexRef.current !== -1) {
92
+ activeIndexRef.current = -1;
93
+ setActiveIndex(-1);
94
+ }
95
+ };
96
+ }, [rendererRootNode, selectorRoot]);
97
+
98
+ useLayoutEffect(() => {
99
+ if (controllerRef.current || !rendererRootNode || !immerserRegistry.isReady) {
100
+ return;
101
+ }
102
+
103
+ const controller = new ImmerserController({
104
+ ...latestControllerOptionsRef.current,
105
+ // React renders masks and solid clones, so the core must only measure and drive them.
106
+ hasExternalRenderer: true,
107
+ on,
108
+ selectorRoot: selectorRoot ?? rendererRootNode.parentNode ?? document,
109
+ });
110
+
111
+ controllerRef.current = controller;
112
+ controller.on('stateChange', syncState);
113
+ syncState(controller);
114
+ }, [rendererRootNode, selectorRoot, immerserRegistry.isReady]);
115
+
116
+ useLayoutEffect(() => {
117
+ if (!immerserRegistry.isReady) {
118
+ return;
119
+ }
120
+
121
+ controllerRef.current?.render();
122
+ }, [children, layerIds, immerserRegistry.isReady, solidClassnamesByLayerId]);
123
+
124
+ useEffect(() => {
125
+ const nextOptions = latestControllerOptionsRef.current;
126
+
127
+ controllerRef.current?.updateOptions(nextOptions);
128
+ }, [debug, fromViewportWidth, updateLocationHash, pagerThreshold, scrollAdjustDelay, scrollAdjustThreshold]);
129
+
130
+ const configContextValue = useMemo(
131
+ () => ({
132
+ layerIds,
133
+ registerLayer: immerserRegistry.registerLayer,
134
+ registerMaskInner: immerserRegistry.registerMaskInner,
135
+ setRendererRootNode,
136
+ solidClassnamesByLayerId,
137
+ unregisterLayer: immerserRegistry.unregisterLayer,
138
+ }),
139
+ [
140
+ layerIds,
141
+ immerserRegistry.registerLayer,
142
+ immerserRegistry.registerMaskInner,
143
+ immerserRegistry.unregisterLayer,
144
+ solidClassnamesByLayerId,
145
+ ],
146
+ );
147
+
148
+ // Keep the context value reference stable between provider renders.
149
+ // React state setters are stable, but the object literal is not.
150
+ const synchroContextValue = useMemo(
151
+ () => ({
152
+ activeSynchroId,
153
+ setActiveSynchroId,
154
+ }),
155
+ [activeSynchroId],
156
+ );
157
+
158
+ return (
159
+ <ImmerserConfigContext.Provider value={configContextValue}>
160
+ <ImmerserContext.Provider value={activeIndex}>
161
+ <ImmerserSynchroContext.Provider value={synchroContextValue}>{children}</ImmerserSynchroContext.Provider>
162
+ </ImmerserContext.Provider>
163
+ </ImmerserConfigContext.Provider>
164
+ );
165
+ };
166
+
167
+ ImmerserProvider.displayName = 'ImmerserProvider';