@octanejs/three 0.1.0 → 0.1.3

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.
@@ -25,10 +25,13 @@ import {
25
25
  import type { ComputeFunction, EventManager } from './events.js';
26
26
  import {
27
27
  createPortalStore,
28
+ readRootStoreRenderSnapshot,
28
29
  RootStoreContext,
30
+ RootStoreRenderSnapshotContext,
29
31
  updateCamera,
30
32
  type RootState,
31
33
  type RootStore,
34
+ type RootStoreRenderSnapshot,
32
35
  type Size,
33
36
  } from './store.js';
34
37
 
@@ -49,24 +52,56 @@ interface PortalProps {
49
52
  readonly state?: InjectState;
50
53
  }
51
54
 
52
- interface PortalOptions {
53
- readonly events: InjectState['events'];
54
- readonly size: Partial<Size> | undefined;
55
+ interface PortalPlan {
56
+ readonly parent: RootState;
57
+ readonly inject: InjectState;
58
+ readonly state: RootState;
59
+ readonly view: RootStoreRenderSnapshot;
60
+ readonly injectedKeys: ReadonlySet<string>;
61
+ readonly injectedEventKeys: ReadonlySet<string>;
55
62
  }
56
63
 
57
64
  interface PortalLayer {
58
65
  readonly store: RootStore;
59
66
  readonly target: ThreePortalTargetBinding;
60
- readonly sync: (parent: RootState, options: PortalOptions) => void;
61
- readonly commitCamera: (parent: RootState, options: PortalOptions) => void;
67
+ readonly stage: (parent: RootState, state: InjectState) => PortalPlan;
68
+ readonly commit: (plan: PortalPlan) => void;
62
69
  }
63
70
 
64
- const PORTAL_OPTIONS = Symbol('octane.three.portal.options');
65
- const PORTAL_OPTIONS_COMMIT = Symbol('octane.three.portal.options-commit');
71
+ const EMPTY_INJECT_STATE = Object.freeze({}) as InjectState;
72
+ const PORTAL_STATE = Symbol('octane.three.portal.state');
73
+ const PORTAL_STATE_COMMIT = Symbol('octane.three.portal.state-commit');
66
74
  const PORTAL_LAYER = Symbol('octane.three.portal.layer');
67
- const PORTAL_CAMERA_COMMIT = Symbol('octane.three.portal.camera-commit');
68
75
  const PORTAL_SUBSCRIPTION = Symbol('octane.three.portal.subscription');
69
76
 
77
+ function shallowEqual(left: object, right: object): boolean {
78
+ const leftKeys = Object.keys(left);
79
+ const rightKeys = Object.keys(right);
80
+ if (leftKeys.length !== rightKeys.length) return false;
81
+ return leftKeys.every(
82
+ (key) =>
83
+ Object.prototype.hasOwnProperty.call(right, key) &&
84
+ Object.is((left as Record<string, unknown>)[key], (right as Record<string, unknown>)[key]),
85
+ );
86
+ }
87
+
88
+ function reuseShallow<T extends object>(previous: T, next: T): T {
89
+ return shallowEqual(previous, next) ? previous : next;
90
+ }
91
+
92
+ function resetRemovedKeys(
93
+ target: Record<string, unknown>,
94
+ previousKeys: ReadonlySet<string>,
95
+ next: Readonly<Record<string, unknown>>,
96
+ fallback: Readonly<Record<string, unknown>>,
97
+ ): void {
98
+ for (const key of previousKeys) {
99
+ if (Object.prototype.hasOwnProperty.call(next, key)) continue;
100
+ if (Object.prototype.hasOwnProperty.call(fallback, key)) target[key] = fallback[key];
101
+ else delete target[key];
102
+ }
103
+ }
104
+
70
105
  function getWorldMatrixSnapshot(object: THREE.Object3D): THREE.Matrix4 {
71
106
  if (object.matrixWorldAutoUpdate === false) return object.matrixWorld.clone();
72
107
  const local = object.matrixAutoUpdate
@@ -98,82 +133,154 @@ function snapshotCamera<T extends THREE.Camera>(camera: T): T {
98
133
  function createLayer(
99
134
  previousRoot: RootStore,
100
135
  container: THREE.Object3D,
136
+ parentState: RootState,
101
137
  state: InjectState,
102
- options: PortalOptions,
103
138
  ): PortalLayer {
104
- const { events: _events, size: _size, ...rest } = state;
139
+ const { events: _events, size: _size, ...initialRest } = state;
105
140
  const raycaster = new THREE.Raycaster();
106
141
  const pointer = new THREE.Vector2();
107
142
  const target = createThreePortalTargetBinding(container);
108
143
  const store = createPortalStore({
109
- ...previousRoot.getState(),
110
- ...rest,
144
+ ...parentState,
145
+ ...initialRest,
111
146
  // A managed portal target may be reconstructed without changing the
112
147
  // authored target handle. The driver advances the shared binding to the
113
148
  // accepted replacement, and subsequent parent-state mirrors retain it.
114
149
  scene: target.current as THREE.Scene,
115
150
  } as RootState);
116
-
117
- const sync = (parent: RootState, currentOptions: PortalOptions) => {
151
+ let injectedKeys = new Set<string>();
152
+ let injectedEventKeys = new Set<string>();
153
+ let cameraCommit:
154
+ | {
155
+ readonly camera: RootState['camera'];
156
+ readonly width: number;
157
+ readonly height: number;
158
+ readonly manual: boolean | undefined;
159
+ }
160
+ | undefined;
161
+ const setEvents = (events: Partial<EventManager<any>>) => {
118
162
  store.setState((local) => {
119
- let viewport: Partial<RootState['viewport']> | undefined;
120
- if (local.camera != null && currentOptions.size !== undefined) {
121
- const size = {
122
- ...parent.size,
123
- ...currentOptions.size,
124
- } as Size;
125
- viewport = parent.viewport.getCurrentViewport(
126
- snapshotCamera(local.camera),
127
- new THREE.Vector3(),
128
- size,
129
- );
130
- }
131
- return {
132
- ...parent,
133
- ...local,
134
- scene: target.current as THREE.Scene,
135
- raycaster,
136
- pointer,
137
- mouse: pointer,
138
- previousRoot,
139
- events: {
140
- ...parent.events,
141
- ...local.events,
142
- ...currentOptions.events,
143
- } as EventManager<any>,
144
- size: {
145
- ...parent.size,
146
- ...currentOptions.size,
147
- },
148
- viewport: {
149
- ...parent.viewport,
150
- ...viewport,
151
- },
152
- setEvents(events: Partial<EventManager<any>>) {
153
- store.setState((value) => ({
154
- events: { ...value.events, ...events },
155
- }));
156
- },
157
- };
163
+ const next = { ...local.events, ...events } as EventManager<any>;
164
+ return shallowEqual(local.events, next) ? local : { events: next };
158
165
  });
159
166
  };
160
- const commitCamera = (parent: RootState, currentOptions: PortalOptions) => {
167
+
168
+ const stage = (parent: RootState, currentState: InjectState): PortalPlan => {
169
+ const { events, size, ...rest } = currentState;
170
+ const nextInjectedKeys = new Set(Object.keys(rest));
171
+ const nextInjectedEventKeys = new Set(Object.keys(events ?? {}));
161
172
  const local = store.getState();
173
+ const inherited = { ...parent, ...local } as RootState & Record<string, unknown>;
174
+ resetRemovedKeys(
175
+ inherited,
176
+ injectedKeys,
177
+ rest as Readonly<Record<string, unknown>>,
178
+ parent as unknown as Readonly<Record<string, unknown>>,
179
+ );
180
+ Object.assign(inherited, rest);
181
+
182
+ const localEvents = { ...local.events } as Record<string, unknown>;
183
+ resetRemovedKeys(
184
+ localEvents,
185
+ injectedEventKeys,
186
+ (events ?? {}) as Readonly<Record<string, unknown>>,
187
+ parent.events as unknown as Readonly<Record<string, unknown>>,
188
+ );
189
+ const nextEvents = reuseShallow(local.events, {
190
+ ...parent.events,
191
+ ...localEvents,
192
+ ...events,
193
+ } as EventManager<any>);
194
+ const nextSize = reuseShallow(local.size, { ...parent.size, ...size } as Size);
195
+ let viewport: Partial<RootState['viewport']> | undefined;
196
+ if (inherited.camera != null && size !== undefined) {
197
+ viewport = parent.viewport.getCurrentViewport(
198
+ snapshotCamera(inherited.camera),
199
+ new THREE.Vector3(),
200
+ nextSize,
201
+ );
202
+ }
203
+ const nextViewport = reuseShallow(local.viewport, {
204
+ ...parent.viewport,
205
+ ...viewport,
206
+ });
207
+ const candidate = {
208
+ ...inherited,
209
+ set: local.set,
210
+ get: local.get,
211
+ scene: target.current as THREE.Scene,
212
+ raycaster,
213
+ pointer,
214
+ mouse: pointer,
215
+ previousRoot,
216
+ events: nextEvents,
217
+ size: nextSize,
218
+ viewport: nextViewport,
219
+ setEvents,
220
+ } as RootState;
221
+ const next = shallowEqual(local, candidate) ? local : candidate;
222
+ return {
223
+ parent,
224
+ inject: currentState,
225
+ state: next,
226
+ view: { store, current: next },
227
+ injectedKeys: nextInjectedKeys,
228
+ injectedEventKeys: nextInjectedEventKeys,
229
+ };
230
+ };
231
+ const commitCamera = (plan: PortalPlan) => {
232
+ const camera = plan.state.camera;
233
+ if (camera == null || plan.inject.size === undefined || camera === plan.parent.camera) {
234
+ cameraCommit = undefined;
235
+ return;
236
+ }
237
+ const size = {
238
+ ...plan.parent.size,
239
+ ...plan.inject.size,
240
+ } as Size;
162
241
  if (
163
- local.camera == null ||
164
- currentOptions.size === undefined ||
165
- local.camera === parent.camera
242
+ cameraCommit?.camera === camera &&
243
+ cameraCommit.width === size.width &&
244
+ cameraCommit.height === size.height &&
245
+ cameraCommit.manual === camera.manual
166
246
  ) {
167
247
  return;
168
248
  }
169
- updateCamera(local.camera, {
170
- ...parent.size,
171
- ...currentOptions.size,
172
- } as Size);
249
+ updateCamera(camera, size);
250
+ cameraCommit = {
251
+ camera,
252
+ width: size.width,
253
+ height: size.height,
254
+ manual: camera.manual,
255
+ };
256
+ };
257
+ const commit = (plan: PortalPlan) => {
258
+ try {
259
+ // The driver advances the binding during the accepted host commit.
260
+ // The staged state is attempt-owned, so reasserting that target here
261
+ // cannot leak through a render that preparation rejects.
262
+ plan.state.scene = target.current as THREE.Scene;
263
+ commitCamera(plan);
264
+ if (store.getState() !== plan.state) store.setState(plan.state, true);
265
+ injectedKeys = new Set(plan.injectedKeys);
266
+ injectedEventKeys = new Set(plan.injectedEventKeys);
267
+ } finally {
268
+ plan.view.current = null;
269
+ }
173
270
  };
174
271
 
175
- sync(previousRoot.getState(), options);
176
- return { store, target, sync, commitCamera };
272
+ // A new layer has no previously accepted state that can be exposed. Seed its
273
+ // private store during construction so imperative `useStore().getState()`
274
+ // reads are complete even before the first accepted insertion commit. Camera
275
+ // mutations remain deferred until acceptance.
276
+ const initialPlan = stage(parentState, state);
277
+ initialPlan.state.scene = target.current as THREE.Scene;
278
+ store.setState(initialPlan.state, true);
279
+ injectedKeys = new Set(initialPlan.injectedKeys);
280
+ injectedEventKeys = new Set(initialPlan.injectedEventKeys);
281
+ initialPlan.view.current = null;
282
+
283
+ return { store, target, stage, commit };
177
284
  }
178
285
 
179
286
  const Portal = defineUniversalComponent<PortalProps>('three', (props) => {
@@ -186,32 +293,28 @@ const Portal = defineUniversalComponent<PortalProps>('three', (props) => {
186
293
  if (activeStore === null) {
187
294
  throw new Error('R3F: createPortal can only be used within the Canvas component!');
188
295
  }
189
- const options = { events: props.state?.events, size: props.state?.size };
190
- const optionsRef = useRef<PortalOptions>(options, PORTAL_OPTIONS);
191
- useInsertionEffect(
192
- () => {
193
- optionsRef.current = options;
194
- },
195
- [options.events, options.size],
196
- PORTAL_OPTIONS_COMMIT,
197
- );
296
+ const parentView = useContext(RootStoreRenderSnapshotContext);
297
+ const parentState = readRootStoreRenderSnapshot(activeStore, parentView);
298
+ const state = props.state ?? EMPTY_INJECT_STATE;
299
+ const stateRef = useRef<InjectState>(state, PORTAL_STATE);
198
300
  const layer = useMemo(
199
- () => createLayer(activeStore, props.container, props.state ?? {}, options),
301
+ () => createLayer(activeStore, props.container, parentState, state),
200
302
  [activeStore, props.container],
201
303
  PORTAL_LAYER,
202
304
  );
305
+ const plan = layer.stage(parentState, state);
203
306
  useInsertionEffect(
204
307
  () => {
205
- layer.commitCamera(activeStore.getState(), optionsRef.current);
308
+ layer.commit(plan);
309
+ stateRef.current = state;
206
310
  },
207
- [activeStore, layer],
208
- PORTAL_CAMERA_COMMIT,
311
+ null,
312
+ PORTAL_STATE_COMMIT,
209
313
  );
210
314
  useLayoutEffect(
211
315
  () => {
212
- return activeStore.subscribe((state) => {
213
- layer.sync(state, optionsRef.current);
214
- layer.commitCamera(state, optionsRef.current);
316
+ return activeStore.subscribe((parent) => {
317
+ layer.commit(layer.stage(parent, stateRef.current));
215
318
  });
216
319
  },
217
320
  [activeStore, layer],
@@ -219,9 +322,11 @@ const Portal = defineUniversalComponent<PortalProps>('three', (props) => {
219
322
  );
220
323
 
221
324
  return universalContext(RootStoreContext, layer.store, () =>
222
- createUniversalPortal(
223
- props.children,
224
- createThreePortalTarget(props.container, layer.store, layer.target),
325
+ universalContext(RootStoreRenderSnapshotContext, plan.view, () =>
326
+ createUniversalPortal(
327
+ props.children,
328
+ createThreePortalTarget(props.container, layer.store, layer.target),
329
+ ),
225
330
  ),
226
331
  );
227
332
  });
package/src/core/root.ts CHANGED
@@ -38,6 +38,7 @@ import {
38
38
  type RootState,
39
39
  type RootStore,
40
40
  type Size,
41
+ type XRManager,
41
42
  } from './store.js';
42
43
  import { createUniversalRoot } from 'octane/universal';
43
44
 
@@ -132,6 +133,10 @@ interface ThreeRootInternals<TCanvas extends CanvasLike> {
132
133
  pendingRender: PendingRender | null;
133
134
  lastConfiguredCamera?: CameraProps;
134
135
  onCreated?: (state: RootState) => void;
136
+ contextLifecycleInitialized: boolean;
137
+ contextLifecycleCleanup: (() => void) | null;
138
+ xrInitialized: boolean;
139
+ xrManager: XRManager | null;
135
140
  }
136
141
 
137
142
  interface RootRecord<TCanvas extends CanvasLike = CanvasLike> {
@@ -204,6 +209,147 @@ function disposeRenderer(renderer: Renderer | null | undefined): void {
204
209
  }
205
210
  }
206
211
 
212
+ interface ContextLifecycleTarget {
213
+ addEventListener(type: string, listener: (event: Event) => void): void;
214
+ removeEventListener(type: string, listener: (event: Event) => void): void;
215
+ }
216
+
217
+ function isContextLifecycleTarget(value: unknown): value is ContextLifecycleTarget {
218
+ const target = value as Partial<ContextLifecycleTarget> | null;
219
+ return (
220
+ target !== null &&
221
+ typeof target === 'object' &&
222
+ typeof target.addEventListener === 'function' &&
223
+ typeof target.removeEventListener === 'function'
224
+ );
225
+ }
226
+
227
+ function initializeContextLifecycle(internals: ThreeRootInternals<any>, renderer: Renderer): void {
228
+ if (internals.contextLifecycleInitialized) return;
229
+ const target = isContextLifecycleTarget(renderer.domElement)
230
+ ? renderer.domElement
231
+ : isContextLifecycleTarget(internals.canvas)
232
+ ? internals.canvas
233
+ : null;
234
+ if (target === null) {
235
+ internals.contextLifecycleInitialized = true;
236
+ return;
237
+ }
238
+
239
+ const handleContextLoss = (event: Event) => {
240
+ event.preventDefault();
241
+ };
242
+ const handleContextRestore = () => {
243
+ if (!internals.disposed) internals.store.getState().invalidate();
244
+ };
245
+ target.addEventListener('webglcontextlost', handleContextLoss);
246
+ try {
247
+ target.addEventListener('webglcontextrestored', handleContextRestore);
248
+ } catch (error) {
249
+ target.removeEventListener('webglcontextlost', handleContextLoss);
250
+ throw error;
251
+ }
252
+ internals.contextLifecycleCleanup = () => {
253
+ try {
254
+ target.removeEventListener('webglcontextlost', handleContextLoss);
255
+ } finally {
256
+ target.removeEventListener('webglcontextrestored', handleContextRestore);
257
+ }
258
+ };
259
+ internals.contextLifecycleInitialized = true;
260
+ }
261
+
262
+ function createXRManager(internals: ThreeRootInternals<any>, renderer: Renderer): XRManager {
263
+ let connected = false;
264
+ let frameCallback: ((timestamp: number, frame?: XRFrame) => void) | null = null;
265
+
266
+ const handleSessionChange = () => {
267
+ if (!connected || internals.disposed) return;
268
+ const xr = renderer.xr;
269
+ if (xr === undefined) return;
270
+ const presenting = xr.isPresenting === true;
271
+ xr.enabled = presenting;
272
+ if (presenting) {
273
+ if (frameCallback === null) {
274
+ let currentFrame!: (timestamp: number, frame?: XRFrame) => void;
275
+ currentFrame = (timestamp, frame) => {
276
+ if (!connected || internals.disposed || frameCallback !== currentFrame) {
277
+ return;
278
+ }
279
+ const state = internals.store.getState();
280
+ if (state.frameloop !== 'never') advance(timestamp, true, state, frame);
281
+ };
282
+ frameCallback = currentFrame;
283
+ }
284
+ xr.setAnimationLoop?.(frameCallback);
285
+ } else {
286
+ frameCallback = null;
287
+ xr.setAnimationLoop?.(null);
288
+ internals.store.getState().invalidate();
289
+ }
290
+ };
291
+
292
+ return {
293
+ connect() {
294
+ if (connected || internals.disposed) return;
295
+ const xr = renderer.xr;
296
+ if (
297
+ xr === undefined ||
298
+ typeof xr.addEventListener !== 'function' ||
299
+ typeof xr.removeEventListener !== 'function'
300
+ ) {
301
+ return;
302
+ }
303
+ xr.addEventListener('sessionstart', handleSessionChange);
304
+ try {
305
+ xr.addEventListener('sessionend', handleSessionChange);
306
+ } catch (error) {
307
+ xr.removeEventListener('sessionstart', handleSessionChange);
308
+ throw error;
309
+ }
310
+ connected = true;
311
+ },
312
+ disconnect() {
313
+ const xr = renderer.xr;
314
+ const wasConnected = connected;
315
+ connected = false;
316
+ frameCallback = null;
317
+ if (xr === undefined) return;
318
+ try {
319
+ if (wasConnected && typeof xr.removeEventListener === 'function') {
320
+ xr.removeEventListener('sessionstart', handleSessionChange);
321
+ }
322
+ } finally {
323
+ try {
324
+ if (wasConnected && typeof xr.removeEventListener === 'function') {
325
+ xr.removeEventListener('sessionend', handleSessionChange);
326
+ }
327
+ } finally {
328
+ try {
329
+ xr.setAnimationLoop?.(null);
330
+ } finally {
331
+ xr.enabled = false;
332
+ }
333
+ }
334
+ }
335
+ },
336
+ };
337
+ }
338
+
339
+ function initializeXR(internals: ThreeRootInternals<any>, renderer: Renderer): void {
340
+ if (internals.xrInitialized) return;
341
+ const xr = createXRManager(internals, renderer);
342
+ xr.connect();
343
+ try {
344
+ internals.store.setState({ xr });
345
+ } catch (error) {
346
+ xr.disconnect();
347
+ throw error;
348
+ }
349
+ internals.xrManager = xr;
350
+ internals.xrInitialized = true;
351
+ }
352
+
207
353
  function computeInitialSize(canvas: CanvasLike, size?: RenderProps<any>['size']): Size {
208
354
  if (size !== undefined) {
209
355
  return { width: size.width, height: size.height, top: size.top ?? 0, left: size.left ?? 0 };
@@ -246,9 +392,12 @@ function applyRaycasterOptions(
246
392
  ...raycaster.params,
247
393
  ...params,
248
394
  Mesh: { ...raycaster.params.Mesh, ...params.Mesh },
249
- Line: { ...raycaster.params.Line, ...params.Line },
395
+ Line: { ...raycaster.params.Line, ...params.Line } as THREE.Raycaster['params']['Line'],
250
396
  LOD: { ...raycaster.params.LOD, ...params.LOD },
251
- Points: { ...raycaster.params.Points, ...params.Points },
397
+ Points: {
398
+ ...raycaster.params.Points,
399
+ ...params.Points,
400
+ } as THREE.Raycaster['params']['Points'],
252
401
  Sprite: { ...raycaster.params.Sprite, ...params.Sprite },
253
402
  };
254
403
  }
@@ -402,6 +551,8 @@ async function applyConfiguration<TCanvas extends CanvasLike>(
402
551
  } = props;
403
552
  const renderer = await ensureRenderer(internals, glConfig, generation);
404
553
  if (renderer === null || internals.disposed || internals.generation !== generation) return;
554
+ initializeContextLifecycle(internals, renderer);
555
+ initializeXR(internals, renderer);
405
556
 
406
557
  let state = internals.store.getState();
407
558
  if (state.raycaster == null) internals.store.setState({ raycaster: new THREE.Raycaster() });
@@ -579,14 +730,23 @@ export function createRoot<TCanvas extends CanvasLike>(canvas: TCanvas): ThreeRo
579
730
  try {
580
731
  internals.hostRoot?.unmount();
581
732
  } finally {
582
- internals.container?.flushDisposals();
583
733
  try {
584
- state.xr.disconnect();
734
+ internals.container?.flushDisposals();
585
735
  } finally {
586
- disposeRenderer(state.gl);
587
- destroyRootStore(store);
588
- roots.delete(canvas);
589
- rootInternals.delete(controller);
736
+ try {
737
+ internals.contextLifecycleCleanup?.();
738
+ } finally {
739
+ internals.contextLifecycleCleanup = null;
740
+ try {
741
+ internals.xrManager?.disconnect();
742
+ } finally {
743
+ internals.xrManager = null;
744
+ disposeRenderer(state.gl);
745
+ destroyRootStore(store);
746
+ roots.delete(canvas);
747
+ rootInternals.delete(controller);
748
+ }
749
+ }
590
750
  }
591
751
  }
592
752
  }
@@ -610,6 +770,10 @@ export function createRoot<TCanvas extends CanvasLike>(canvas: TCanvas): ThreeRo
610
770
  generation: 0,
611
771
  pendingRender: null,
612
772
  lastConfiguredCamera: undefined,
773
+ contextLifecycleInitialized: false,
774
+ contextLifecycleCleanup: null,
775
+ xrInitialized: false,
776
+ xrManager: null,
613
777
  };
614
778
  rootInternals.set(controller, internals);
615
779
  roots.set(canvas, { store, root: controller });
@@ -617,8 +781,14 @@ export function createRoot<TCanvas extends CanvasLike>(canvas: TCanvas): ThreeRo
617
781
  return controller;
618
782
  }
619
783
 
620
- export function unmountComponentAtNode<TCanvas extends CanvasLike>(canvas: TCanvas): void {
621
- roots.get(canvas)?.root.unmount();
784
+ export function unmountComponentAtNode<TCanvas extends CanvasLike>(
785
+ canvas: TCanvas,
786
+ callback?: (canvas: TCanvas) => void,
787
+ ): void {
788
+ const record = roots.get(canvas);
789
+ if (record === undefined) return;
790
+ record.root.unmount();
791
+ callback?.(canvas);
622
792
  }
623
793
 
624
794
  export interface ThreeBoundaryMount {
package/src/core/store.ts CHANGED
@@ -6,7 +6,13 @@
6
6
  * components. Zustand remains the framework-neutral storage primitive.
7
7
  */
8
8
  import * as THREE from 'three';
9
- import { createContext, useRef, useSyncExternalStore, withSlot } from 'octane/universal';
9
+ import {
10
+ createContext,
11
+ useContext,
12
+ useRef,
13
+ useSyncExternalStore,
14
+ withSlot,
15
+ } from 'octane/universal';
10
16
  import { createStore as createVanillaStore, type StoreApi } from 'zustand/vanilla';
11
17
  import type {
12
18
  DomEvent,
@@ -453,6 +459,31 @@ interface SelectionCell<T> {
453
459
  value: T;
454
460
  }
455
461
 
462
+ /**
463
+ * Render-attempt-local state exposed by a portal without mutating its live
464
+ * store. An accepted portal commit clears `current`, after which consumers
465
+ * resume reading the store itself.
466
+ *
467
+ * This is package-private plumbing for the Three renderer. It is exported from
468
+ * this module so the portal and hook implementations can share it, but it is
469
+ * intentionally absent from the package's public barrel.
470
+ */
471
+ export interface RootStoreRenderSnapshot {
472
+ readonly store: RootStore;
473
+ current: RootState | null;
474
+ }
475
+
476
+ export const RootStoreRenderSnapshotContext = createContext<RootStoreRenderSnapshot | null>(null);
477
+
478
+ export function readRootStoreRenderSnapshot(
479
+ store: RootStore,
480
+ snapshot: RootStoreRenderSnapshot | null,
481
+ ): RootState {
482
+ return snapshot?.store === store && snapshot.current !== null
483
+ ? snapshot.current
484
+ : store.getState();
485
+ }
486
+
456
487
  /** Universal-hook selector used by the callable store and `useThree`. */
457
488
  export function useRootStoreSelector<T>(
458
489
  store: RootStore,
@@ -460,12 +491,13 @@ export function useRootStoreSelector<T>(
460
491
  equalityFn: (previous: T, next: T) => boolean = Object.is,
461
492
  slot?: unknown,
462
493
  ): T {
494
+ const renderSnapshot = useContext(RootStoreRenderSnapshotContext);
463
495
  const run = (nested: boolean): T => {
464
496
  const cell = nested
465
497
  ? useRef<SelectionCell<T>>({ initialized: false, value: undefined as T }, 'selection')
466
498
  : useRef<SelectionCell<T>>({ initialized: false, value: undefined as T });
467
499
  const snapshot = () => {
468
- const next = selector(store.getState());
500
+ const next = selector(readRootStoreRenderSnapshot(store, renderSnapshot));
469
501
  if (!cell.current.initialized || !equalityFn(cell.current.value, next)) {
470
502
  cell.current = { initialized: true, value: next };
471
503
  }