diff --git a/.changeset/good-masks-play.md b/.changeset/good-masks-play.md new file mode 100644 index 0000000000..6d235d7499 --- /dev/null +++ b/.changeset/good-masks-play.md @@ -0,0 +1,5 @@ +--- +'@xstate/react': minor +--- + +Fast refresh now works as expected for most use-cases. diff --git a/packages/core/src/State.ts b/packages/core/src/State.ts index ac4bb10240..aeece9ce56 100644 --- a/packages/core/src/State.ts +++ b/packages/core/src/State.ts @@ -299,7 +299,8 @@ export function getPersistedState< TTag, TOutput, TResolvedTypesMeta - > + >, + options?: unknown ): Snapshot { const { configuration, tags, machine, children, context, ...jsonValues } = state; @@ -308,11 +309,15 @@ export function getPersistedState< for (const id in children) { const child = children[id] as any; - if (isDevelopment && typeof child.src !== 'string') { + if ( + isDevelopment && + typeof child.src !== 'string' && + (!options || !('__unsafeAllowInlineActors' in (options as object))) + ) { throw new Error('An inline child actor cannot be persisted.'); } childrenJson[id as keyof typeof childrenJson] = { - state: child.getPersistedState(), + state: child.getPersistedState(options), src: child.src, systemId: child._systemId }; diff --git a/packages/core/src/StateMachine.ts b/packages/core/src/StateMachine.ts index a8a678888c..21df12d247 100644 --- a/packages/core/src/StateMachine.ts +++ b/packages/core/src/StateMachine.ts @@ -45,7 +45,8 @@ import type { Equals, TODO, SnapshotFrom, - Snapshot + Snapshot, + AnyActorLogic } from './types.ts'; import { isErrorActorEvent, resolveReferencedActor } from './utils.ts'; import { $$ACTOR_TYPE, createActor } from './interpreter.ts'; @@ -180,7 +181,6 @@ export class StateMachine< this.getInitialState = this.getInitialState.bind(this); this.restoreState = this.restoreState.bind(this); this.start = this.start.bind(this); - this.getPersistedState = this.getPersistedState.bind(this); this.root = new StateNode(config, { _key: this.id, @@ -534,9 +534,10 @@ export class StateMachine< TTag, TOutput, TResolvedTypesMeta - > + >, + options?: unknown ) { - return getPersistedState(state); + return getPersistedState(state, options); } public createState( @@ -587,7 +588,11 @@ export class StateMachine< const children: Record = {}; const snapshotChildren: Record< string, - { src: string; state: Snapshot; systemId?: string } + { + src: string | AnyActorLogic; + state: Snapshot; + systemId?: string; + } > = (snapshot as any).children; Object.keys(snapshotChildren).forEach((actorId) => { @@ -596,7 +601,8 @@ export class StateMachine< const childState = actorData.state; const src = actorData.src; - const logic = src ? resolveReferencedActor(this, src)?.src : undefined; + const logic = + typeof src === 'string' ? resolveReferencedActor(this, src)?.src : src; if (!logic) { return; diff --git a/packages/core/src/actions/spawn.ts b/packages/core/src/actions/spawn.ts index 89d7b1d220..5957356f61 100644 --- a/packages/core/src/actions/spawn.ts +++ b/packages/core/src/actions/spawn.ts @@ -59,7 +59,7 @@ function resolveSpawn( const configuredInput = input || referenced.input; actorRef = createActor(referenced.src, { id: resolvedId, - src: typeof src === 'string' ? src : undefined, + src, parent: actorScope?.self, systemId, input: diff --git a/packages/core/src/interpreter.ts b/packages/core/src/interpreter.ts index a7ee6f3e29..b6d7db3bff 100644 --- a/packages/core/src/interpreter.ts +++ b/packages/core/src/interpreter.ts @@ -129,7 +129,7 @@ export class Actor public system: ActorSystem; private _doneEvent?: DoneActorEvent; - public src?: string; + public src: string | AnyActorLogic; /** * Creates a new actor instance for the given logic with the provided options, if any. @@ -158,7 +158,7 @@ export class Actor this.clock = clock; this._parent = parent; this.options = resolvedOptions; - this.src = resolvedOptions.src; + this.src = resolvedOptions.src ?? logic; this.ref = this; this._actorScope = { self: this, @@ -186,7 +186,7 @@ export class Actor type: '@xstate.actor', actorRef: this }); - this._initState(); + this._initState(options?.state); if (systemId && (this._state as any).status === 'active') { this._systemId = systemId; @@ -194,11 +194,11 @@ export class Actor } } - private _initState() { - this._state = this.options.state + private _initState(persistedState?: Snapshot) { + this._state = persistedState ? this.logic.restoreState - ? this.logic.restoreState(this.options.state, this._actorScope) - : this.options.state + ? this.logic.restoreState(persistedState, this._actorScope) + : persistedState : this.logic.getInitialState(this._actorScope, this.options?.input); } @@ -217,7 +217,6 @@ export class Actor } for (const observer of this.observers) { - // TODO: should observers be notified in case of the error? try { observer.next?.(snapshot); } catch (err) { @@ -357,6 +356,7 @@ export class Actor } this._processingStatus = ProcessingStatus.Running; + // TODO: this isn't correct when rehydrating const initEvent = createInitEvent(this.options.input); this.system._sendInspectionEvent({ @@ -598,8 +598,9 @@ export class Actor }; } - public getPersistedState(): Snapshot { - return this.logic.getPersistedState(this._state); + public getPersistedState(): Snapshot; + public getPersistedState(options?: unknown): Snapshot { + return this.logic.getPersistedState(this._state, options); } public [symbolObservable](): InteropSubscribable> { diff --git a/packages/core/src/spawn.ts b/packages/core/src/spawn.ts index 7666479922..a0a92bd49a 100644 --- a/packages/core/src/spawn.ts +++ b/packages/core/src/spawn.ts @@ -109,12 +109,11 @@ export function createSpawner( } return actorRef; } else { - // TODO: this should also receive `src` const actorRef = createActor(src, { id: options.id, parent: actorScope.self, input: options.input, - src: undefined, + src, systemId }); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index b9a431c1e8..8b87e5fb9a 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1702,7 +1702,7 @@ export interface ActorOptions { /** * The source definition. */ - src?: string; + src?: string | AnyActorLogic; inspect?: | Observer @@ -1790,7 +1790,7 @@ export interface ActorRef< system?: ActorSystem; /** @internal */ _processingStatus: ProcessingStatus; - src?: string; + src: string | AnyActorLogic; } export type AnyActorRef = ActorRef; @@ -1981,7 +1981,7 @@ export interface ActorLogic< /** * @returns Persisted state */ - getPersistedState: (state: TSnapshot) => Snapshot; + getPersistedState: (state: TSnapshot, options?: unknown) => Snapshot; } export type AnyActorLogic = ActorLogic< diff --git a/packages/xstate-react/src/stopRootWithRehydration.ts b/packages/xstate-react/src/stopRootWithRehydration.ts new file mode 100644 index 0000000000..4cd906b52a --- /dev/null +++ b/packages/xstate-react/src/stopRootWithRehydration.ts @@ -0,0 +1,35 @@ +import { AnyActorRef, Snapshot } from 'xstate'; + +const forEachActor = ( + actorRef: AnyActorRef, + callback: (ref: AnyActorRef) => void +) => { + callback(actorRef); + const children = actorRef.getSnapshot().children; + if (children) { + Object.values(children).forEach((child) => { + forEachActor(child as AnyActorRef, callback); + }); + } +}; + +export function stopRootWithRehydration(actorRef: AnyActorRef) { + // persist state here in a custom way allows us to persist inline actors and to preserve actor references + // we do it to avoid setState in useEffect when the effect gets "reconnected" + // this currently only happens in Strict Effects but it simulates the Offscreen aka Activity API + // it also just allows us to end up with a somewhat more predictable behavior for the users + const persistedSnapshots: Array<[AnyActorRef, Snapshot]> = []; + forEachActor(actorRef, (ref) => { + persistedSnapshots.push([ref, ref.getSnapshot()]); + // muting observers allow us to avoid `useSelector` from being notified about the stopped state + // React reconnects its subscribers (from the useSyncExternalStore) on its own + // and userland subscibers should basically always do the same anyway + // as each subscription should have its own cleanup logic and that should be called each such reconnect + (ref as any).observers = new Set(); + }); + actorRef.stop(); + persistedSnapshots.forEach(([ref, snapshot]) => { + (ref as any)._processingStatus = 0; + (ref as any)._state = snapshot; + }); +} diff --git a/packages/xstate-react/src/useActor.ts b/packages/xstate-react/src/useActor.ts index 8b76ba3032..782cc28ce1 100644 --- a/packages/xstate-react/src/useActor.ts +++ b/packages/xstate-react/src/useActor.ts @@ -7,7 +7,8 @@ import { ActorOptions, SnapshotFrom } from 'xstate'; -import { useIdleInterpreter } from './useActorRef.ts'; +import { useIdleActor } from './useActorRef.ts'; +import { stopRootWithRehydration } from './stopRootWithRehydration.ts'; export function useActor( logic: TLogic, @@ -24,7 +25,7 @@ export function useActor( ); } - const actorRef = useIdleInterpreter(logic, options as any); + const actorRef = useIdleActor(logic, options as any); const getSnapshot = useCallback(() => { return actorRef.getSnapshot(); @@ -48,9 +49,7 @@ export function useActor( actorRef.start(); return () => { - actorRef.stop(); - (actorRef as any)._processingStatus = 0; - (actorRef as any)._initState(); + stopRootWithRehydration(actorRef); }; }, [actorRef]); diff --git a/packages/xstate-react/src/useActorRef.ts b/packages/xstate-react/src/useActorRef.ts index 238f5cdfc5..41a6492280 100644 --- a/packages/xstate-react/src/useActorRef.ts +++ b/packages/xstate-react/src/useActorRef.ts @@ -1,4 +1,3 @@ -import isDevelopment from '#is-development'; import { useEffect, useState } from 'react'; import { AnyActorLogic, @@ -15,35 +14,37 @@ import { SnapshotFrom, TODO } from 'xstate'; -import useConstant from './useConstant.ts'; import useIsomorphicLayoutEffect from 'use-isomorphic-layout-effect'; +import { stopRootWithRehydration } from './stopRootWithRehydration'; -export function useIdleInterpreter( - machine: AnyActorLogic, +export function useIdleActor( + logic: AnyActorLogic, options: Partial> ): AnyActor { - if (isDevelopment) { - const [initialMachine] = useState(machine); + let [[currentConfig, actorRef], setCurrent] = useState(() => { + const actorRef = createActor(logic, options); + return [logic.config, actorRef]; + }); - if (machine.config !== initialMachine.config) { - console.warn( - `Actor logic has changed between renders. This is not supported and may lead to invalid snapshots.` - ); - } + if (logic.config !== currentConfig) { + const newActorRef = createActor(logic, { + ...options, + state: (actorRef.getPersistedState as any)({ + __unsafeAllowInlineActors: true + }) + }); + setCurrent([logic.config, newActorRef]); + actorRef = newActorRef; } - const actorRef = useConstant(() => { - return createActor(machine as AnyStateMachine, options); - }); - // TODO: consider using `useAsapEffect` that would do this in `useInsertionEffect` is that's available useIsomorphicLayoutEffect(() => { (actorRef.logic as AnyStateMachine).implementations = ( - machine as AnyStateMachine + logic as AnyStateMachine ).implementations; }); - return actorRef as any; + return actorRef; } export function useActorRef( @@ -53,7 +54,7 @@ export function useActorRef( | Observer> | ((value: SnapshotFrom) => void) ): ActorRefFrom { - const actorRef = useIdleInterpreter(machine, options); + const actorRef = useIdleActor(machine, options); useEffect(() => { if (!observerOrListener) { @@ -69,11 +70,9 @@ export function useActorRef( actorRef.start(); return () => { - actorRef.stop(); - (actorRef as any)._processingStatus = 0; - (actorRef as any)._initState(); + stopRootWithRehydration(actorRef); }; - }, []); + }, [actorRef]); return actorRef as any; } diff --git a/packages/xstate-react/src/useConstant.ts b/packages/xstate-react/src/useConstant.ts deleted file mode 100644 index e992596707..0000000000 --- a/packages/xstate-react/src/useConstant.ts +++ /dev/null @@ -1,15 +0,0 @@ -import * as React from 'react'; - -interface ResultBox { - v: T; -} - -export default function useConstant(fn: () => T): T { - const ref = React.useRef>(); - - if (!ref.current) { - ref.current = { v: fn() }; - } - - return ref.current.v; -} diff --git a/packages/xstate-react/test/useActor.test.tsx b/packages/xstate-react/test/useActor.test.tsx index 480c4033a7..935be2b5ce 100644 --- a/packages/xstate-react/test/useActor.test.tsx +++ b/packages/xstate-react/test/useActor.test.tsx @@ -5,7 +5,6 @@ import { Actor, ActorLogicFrom, ActorRef, - ActorRefFrom, DoneActorEvent, Snapshot, StateFrom, @@ -118,7 +117,7 @@ describeEachReactMode('useActor (%s)', ({ suiteKey, render }) => { } }; - it('should work with the useMachine hook', async () => { + it('should work with the useActor hook', async () => { render( new Promise((res) => res('fake data'))} />); const button = screen.getByText('Fetch'); fireEvent.click(button); @@ -128,7 +127,7 @@ describeEachReactMode('useActor (%s)', ({ suiteKey, render }) => { expect(dataEl.textContent).toBe('fake data'); }); - it('should work with the useMachine hook (rehydrated state)', async () => { + it('should work with the useActor hook (rehydrated state)', async () => { render( new Promise((res) => res('fake data'))} @@ -463,14 +462,7 @@ describeEachReactMode('useActor (%s)', ({ suiteKey, render }) => { render(); - expect(rerenders).toBe( - suiteKey === 'strict' - ? // it's rendered twice for the each state - // and the machine gets currently completely restarted in a double-invoked strict effect - // so we get a new state from that restarted machine (and thus 2 additional strict renders) and we end up with 4 - 4 - : 1 - ); + expect(rerenders).toBe(suiteKey === 'strict' ? 2 : 1); }); it('should maintain the same reference for objects created when resolving initial state', () => { @@ -522,20 +514,12 @@ describeEachReactMode('useActor (%s)', ({ suiteKey, render }) => { const { getByRole } = render(); - expect(effectsFired).toBe( - suiteKey === 'strict' - ? // TODO: probably it should be 2 for strict mode cause of the double-invoked strict effects - // atm it's 3 cause we the double-invoked effect sees the initial value - // but the 3rd call comes from the restarted machine (that happens because of the strict effects) - // the second effect with `service.start()` doesn't have a way to change what another effect in the same "effect batch" sees - 3 - : 1 - ); + expect(effectsFired).toBe(suiteKey === 'strict' ? 2 : 1); const button = getByRole('button'); fireEvent.click(button); - expect(effectsFired).toBe(suiteKey === 'strict' ? 3 : 1); + expect(effectsFired).toBe(suiteKey === 'strict' ? 2 : 1); }); it('should successfully spawn actors from the lazily declared context', () => { @@ -855,25 +839,34 @@ describeEachReactMode('useActor (%s)', ({ suiteKey, render }) => { } }); - const machine = createMachine({ - initial: 'active', - states: { - active: { - invoke: { - id: 'test', - src: childMachine, - input: { value: 42 } + const machine = createMachine( + { + types: {} as { + actors: { + src: 'child'; + logic: typeof childMachine; + id: 'test'; + }; + }, + initial: 'active', + states: { + active: { + invoke: { + src: 'child', + id: 'test', + input: { value: 42 } + } } } + }, + { + actors: { child: childMachine } } - }); + ); const Test = () => { const [state] = useActor(machine); - const childState = useSelector( - state.children.test as ActorRefFrom, // TODO: introduce typing for this in machine types - (s) => s - ); + const childState = useSelector(state.children.test!, (s) => s); expect(childState.context.value).toBe(42); @@ -1000,12 +993,6 @@ describeEachReactMode('useActor (%s)', ({ suiteKey, render }) => { render(); - expect(spy).toHaveBeenCalledTimes( - suiteKey === 'strict' - ? // TODO: probably it should be 2 for strict mode cause of the double-invoked strict effects - // but we don't rehydrate child actors right now, we just recreate the initial state and that leads to an extra render with strict effects - 3 - : 1 - ); + expect(spy).toHaveBeenCalledTimes(suiteKey === 'strict' ? 2 : 1); }); }); diff --git a/packages/xstate-react/test/useActorRef.test.tsx b/packages/xstate-react/test/useActorRef.test.tsx index 575a95ad3f..272415791b 100644 --- a/packages/xstate-react/test/useActorRef.test.tsx +++ b/packages/xstate-react/test/useActorRef.test.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { ActorRefFrom, + AnyStateMachine, assign, createMachine, fromPromise, @@ -37,21 +38,21 @@ describeEachReactMode('useActorRef (%s)', ({ suiteKey, render }) => { }); const App = () => { - const service = useActorRef(machine); + const actorRef = useActorRef(machine); React.useEffect(() => { - service.subscribe((state) => { + actorRef.subscribe((state) => { if (state.matches('active')) { done(); } }); - }, [service]); + }, [actorRef]); return ( ); @@ -105,44 +106,7 @@ describeEachReactMode('useActorRef (%s)', ({ suiteKey, render }) => { expect(actual).toEqual([42]); }); - it('should warn when machine reference is updated during the hook lifecycle', () => { - console.warn = jest.fn(); - const createTestMachine = () => createMachine({}); - const App = () => { - const [, setId] = React.useState(1); - useMachine(createTestMachine()); - - return ( - <> - - - ); - }; - - render(); - - fireEvent.click(screen.getByRole('button')); - - expect(console.warn).toHaveBeenCalledTimes(suiteKey === 'strict' ? 4 : 1); - expect((console.warn as jest.Mock).mock.calls[0][0]).toMatchInlineSnapshot( - `"Actor logic has changed between renders. This is not supported and may lead to invalid snapshots."` - ); - if (suiteKey === 'strict') { - expect( - (console.warn as jest.Mock).mock.calls[1][0] - ).toMatchInlineSnapshot( - `"Actor logic has changed between renders. This is not supported and may lead to invalid snapshots."` - ); - } - }); - - it('should not warn when only the provided machine implementations have changed', () => { + it('should rerender OK when only the provided machine implementations have changed', () => { console.warn = jest.fn(); const machine = createMachine({ initial: 'foo', @@ -179,6 +143,7 @@ describeEachReactMode('useActorRef (%s)', ({ suiteKey, render }) => { > update id + {id} ); }; @@ -187,7 +152,7 @@ describeEachReactMode('useActorRef (%s)', ({ suiteKey, render }) => { fireEvent.click(screen.getByRole('button')); - expect(console.warn).not.toHaveBeenCalled(); + expect(screen.getByText('2')).toBeTruthy(); }); it('should change state when started', async () => { @@ -379,132 +344,423 @@ describeEachReactMode('useActorRef (%s)', ({ suiteKey, render }) => { await testWaitFor(() => expect(count.textContent).toBe('42')); }); - // TODO: reexecuted layout effect in strict mode sees the outdated state - // it fires after passive cleanup (that stops the machine) and before the passive setup (that restarts the machine) - (suiteKey === 'strict' ? it.skip : it)( - 'invoked actor should be able to receive (deferred) events that it replays when active', - (done) => { - const childMachine = createMachine({ - id: 'childMachine', - initial: 'active', - states: { - active: { - on: { - FINISH: { actions: sendParent({ type: 'FINISH' }) } - } + it('invoked actor should be able to receive (deferred) events that it replays when active', () => { + let isDone = false; + + const childMachine = createMachine({ + id: 'childMachine', + initial: 'active', + states: { + active: { + on: { + FINISH: { actions: sendParent({ type: 'FINISH' }) } } } - }); - const machine = createMachine({ - initial: 'active', - invoke: { - id: 'child', - src: childMachine + } + }); + const machine = createMachine({ + initial: 'active', + invoke: { + id: 'child', + src: childMachine + }, + states: { + active: { + on: { FINISH: 'success' } }, - states: { - active: { - on: { FINISH: 'success' } - }, - success: {} - } - }); + success: {} + } + }); - const ChildTest: React.FC<{ - actor: ActorRefFrom; - }> = ({ actor }) => { - const state = useSelector(actor, (s) => s); + const ChildTest: React.FC<{ + actor: ActorRefFrom; + }> = ({ actor }) => { + const state = useSelector(actor, (s) => s); - expect(state.value).toEqual('active'); + expect(state.value).toEqual('active'); - React.useLayoutEffect(() => { + React.useLayoutEffect(() => { + if (actor.getSnapshot().status === 'active') { actor.send({ type: 'FINISH' }); - }, []); + } + }, []); - return null; - }; + return null; + }; - const Test = () => { - const actorRef = useActorRef(machine); - const childActor = useSelector( - actorRef, - (s) => s.children.child as ActorRefFrom - ); + const Test = () => { + const actorRef = useActorRef(machine); + const childActor = useSelector( + actorRef, + (s) => s.children.child as ActorRefFrom + ); - const isDone = useSelector(actorRef, (s) => s.matches('success')); + isDone = useSelector(actorRef, (s) => s.matches('success')); - if (isDone) { - done(); - } + return ; + }; - return ; - }; + render(); - render(); - } - ); - - // TODO: reexecuted layout effect in strict mode sees the outdated state - // it fires after passive cleanup (that stops the machine) and before the passive setup (that restarts the machine) - (suiteKey === 'strict' ? it.skip : it)( - 'spawned actor should be able to receive (deferred) events that it replays when active', - (done) => { - const childMachine = createMachine({ - id: 'childMachine', - initial: 'active', - states: { - active: { - on: { - FINISH: { actions: sendParent({ type: 'FINISH' }) } - } + expect(isDone).toBe(true); + }); + + it('spawned actor should be able to receive (deferred) events that it replays when active', () => { + let isDone = false; + + const childMachine = createMachine({ + id: 'childMachine', + initial: 'active', + states: { + active: { + on: { + FINISH: { actions: sendParent({ type: 'FINISH' }) } } } - }); - const machine = createMachine({ - initial: 'active', - states: { - active: { - entry: assign({ - actorRef: ({ spawn }) => spawn(childMachine, { id: 'child' }) - }), - on: { FINISH: 'success' } - }, - success: {} + } + }); + const machine = createMachine({ + initial: 'active', + states: { + active: { + entry: assign({ + actorRef: ({ spawn }) => spawn(childMachine, { id: 'child' }) + }), + on: { FINISH: 'success' } + }, + success: {} + } + }); + + const ChildTest: React.FC<{ + actor: ActorRefFrom; + }> = ({ actor }) => { + const state = useSelector(actor, (s) => s); + + expect(state.value).toEqual('active'); + + React.useLayoutEffect(() => { + if (actor.getSnapshot().status === 'active') { + actor.send({ type: 'FINISH' }); } + }, []); + + return null; + }; + + const Test = () => { + const actorRef = useActorRef(machine); + const childActor = useSelector( + actorRef, + (s) => s.children.child as ActorRefFrom + ); + + isDone = useSelector(actorRef, (s) => s.matches('success')); + + return ; + }; + + render(); + + expect(isDone).toBe(true); + }); + + it('should be able to rerender with a new machine', () => { + const machine1 = createMachine({ + initial: 'a', + states: { a: {} } + }); + + const machine2 = createMachine({ + initial: 'a', + states: { + a: { + on: { NEXT: 'b' } + }, + b: {} + } + }); + + function Test() { + const [machine, setMachine] = React.useState(machine1); + const actorRef = useActorRef(machine); + const value = useSelector(actorRef, (state) => state.value); + + return ( + <> + + + {value} + + ); + } + + render(); + + fireEvent.click(screen.getByText('Reload machine')); + fireEvent.click(screen.getByText('Send event')); + + expect(screen.getByText('b')).toBeTruthy(); + }); + + it('should be able to rehydrate an incoming new machine using the persisted state of the previous one', () => { + const machine1 = createMachine({ + initial: 'a', + states: { + a: { + on: { NEXT: 'b' } + }, + b: {} + } + }); + + const machine2 = createMachine({ + initial: 'b', + states: { + b: { + on: { NEXT: 'c' } + }, + c: {} + } + }); + + function Test() { + const [machine, setMachine] = React.useState(machine1); + const actorRef = useActorRef(machine); + const value = useSelector(actorRef, (state) => state.value); + + return ( + <> + + + {value} + + ); + } + + render(); + + fireEvent.click(screen.getByText('Send event')); + fireEvent.click(screen.getByText('Reload machine')); + fireEvent.click(screen.getByText('Send event')); + + expect(screen.getByText('c')).toBeTruthy(); + }); + + it('should not create extra rerenders when recreating the actor on the machine change', () => { + let rerenders = 0; + + const machine1 = createMachine({}); + + const machine2 = createMachine({}); + + function Test() { + const [machine, setMachine] = React.useState(machine1); + useActorRef(machine); + + rerenders++; + + return ( + <> + + + ); + } + + render(); + + fireEvent.click(screen.getByText('Reload machine')); + + // while those numbers might be a little bit surprising at first glance they are actually correct + // we are using the "derive state from props pattern" here and that involved 2 renders + // so we have a first render and then two other renders when the machine changes + // and in strict mode every render is simply doubled + expect(rerenders).toBe(suiteKey === 'strict' ? 6 : 3); + }); + + it('all renders should be consistent - a value derived in render should be derived from the latest source', () => { + let detectedInconsistency = false; + + const machine1 = createMachine({ + tags: ['m1'] + }); + + const machine2 = createMachine({ + tags: ['m2'] + }); + + function Test() { + const [machine, setMachine] = React.useState(machine1); + const actorRef = useActorRef(machine); + const tag = useSelector(actorRef, (state) => [...state.tags][0]); + + detectedInconsistency ||= machine.config.tags[0] !== tag; + + return ( + <> + + + ); + } + + render(); + + fireEvent.click(screen.getByText('Reload machine')); + + expect(detectedInconsistency).toBe(false); + }); + + it('all commits should be consistent - a value derived in render should be derived from the latest source', () => { + let detectedInconsistency = false; + + const machine1 = createMachine({ + tags: ['m1'] + }); + + const machine2 = createMachine({ + tags: ['m2'] + }); + + function Test() { + React.useEffect(() => { + detectedInconsistency ||= machine.config.tags[0] !== tag; }); - const ChildTest: React.FC<{ - actor: ActorRefFrom; - }> = ({ actor }) => { - const state = useSelector(actor, (s) => s); + const [machine, setMachine] = React.useState(machine1); + const actorRef = useActorRef(machine); + const tag = useSelector(actorRef, (state) => [...state.tags][0]); - expect(state.value).toEqual('active'); + return ( + <> + + + ); + } - React.useLayoutEffect(() => { - actor.send({ type: 'FINISH' }); - }, []); + render(); - return null; - }; + fireEvent.click(screen.getByText('Reload machine')); - const Test = () => { - const actorRef = useActorRef(machine); - const childActor = useSelector( - actorRef, - (s) => s.children.child as ActorRefFrom - ); + expect(detectedInconsistency).toBe(false); + }); - const isDone = useSelector(actorRef, (s) => s.matches('success')); + it('should be able to rehydrate an inline actor when changing machines', () => { + const spy = jest.fn(); - if (isDone) { - done(); + const createSampleMachine = (counter: number) => { + const child = createMachine({ + on: { + EV: { + actions: () => { + spy(counter); + } + } } + }); - return ; - }; + return createMachine({ + context: ({ spawn }) => { + return { + childRef: spawn(child) + }; + } + }); + }; - render(); + const machine1 = createSampleMachine(1); + const machine2 = createSampleMachine(2); + + function Test() { + const [machine, setMachine] = React.useState(machine1); + const actorRef = useActorRef(machine); + + return ( + <> + + + + ); } - ); + + render(); + + fireEvent.click(screen.getByText('Reload machine')); + fireEvent.click(screen.getByText('Send event')); + + expect(spy.mock.calls).toHaveLength(1); + // we don't have any means to rehydrate an inline actor with a new src (can't locate its new src) + // so the best we can do is to reuse the old src + expect(spy.mock.calls[0][0]).toBe(1); + }); it("should execute action bound to a specific machine's instance when the action is provided in render", () => { const spy1 = jest.fn(); diff --git a/packages/xstate-react/test/useSelector.test.tsx b/packages/xstate-react/test/useSelector.test.tsx index e8f01cda9d..c418001a33 100644 --- a/packages/xstate-react/test/useSelector.test.tsx +++ b/packages/xstate-react/test/useSelector.test.tsx @@ -474,17 +474,17 @@ describeEachReactMode('useSelector (%s)', ({ suiteKey, render }) => { }); it("should render snapshot value when actor doesn't emit anything", () => { - const createCustomActor = (latestValue: string) => - createActor(fromTransition((s) => s, latestValue)); + const createCustomLogic = (latestValue: string) => + fromTransition((s) => s, latestValue); const parentMachine = createMachine({ types: { context: {} as { - childActor: ReturnType; + childActor: ActorRefFrom; } }, - context: () => ({ - childActor: createCustomActor('foo') + context: ({ spawn }) => ({ + childActor: spawn(createCustomLogic('foo')) }) });