Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Review "inlined Emitter" pattern with the dev team #928

Closed
samreid opened this issue Jan 19, 2019 · 47 comments
Closed

Review "inlined Emitter" pattern with the dev team #928

samreid opened this issue Jan 19, 2019 · 47 comments

Comments

@samreid
Copy link
Member

samreid commented Jan 19, 2019

From #843 (comment)

The simplest and most straightforward way to add an event to the PhET-iO data stream is by instrumenting an Emitter. This is true for both record/playback events as well as purely data-stream events. For instance, scenery/Input.js used to have sections like:

scenery/js/input/Input.js

Lines 465 to 483 in 80af1e4

// called for each touch point
touchStart: function( id, point, event ) {
sceneryLog && sceneryLog.Input && sceneryLog.Input( 'touchStart(\'' + id + '\',' + Input.debugText( point, event ) + ');' );
sceneryLog && sceneryLog.Input && sceneryLog.push();
if ( this.emitter.hasListeners() ) {
this.emitter.emit3( 'touchStart', {
id: id,
point: point.toStateObject(),
event: Input.serializeDomEvent( event )
}, NORMAL_FREQUENCY );
}
var touch = new Touch( id, point, event );
this.addPointer( touch );
this.branchChangeEvents( touch, event, false );
this.downEvent( touch, event );
sceneryLog && sceneryLog.Input && sceneryLog.pop();
},

This has been rewritten into an Emitter so it can be emitted in the data stream and used for recording/playback:

scenery/js/input/Input.js

Lines 366 to 385 in 1021233

// @private {Emitter} - Emits to the PhET-iO data stream.
this.touchStartedEmitter = new Emitter( {
phetioPlayback: true,
tandem: options.tandem.createTandem( 'touchStartedEmitter' ),
// TODO: use of both of these is redundant, and should get fixed with https://github.com/phetsims/axon/issues/194
argumentTypes: [ { valueType: 'number' }, { valueType: Vector2 }, { valueType: window.Event } ],
phetioType: EmitterIO( [
{ name: 'id', type: NumberIO },
{ name: 'point', type: Vector2IO },
{ name: 'event', type: DOMEventIO }
] ),
phetioEventType: 'user',
phetioDocumentation: 'Emits when a touch begins',
listener: ( id, point, event ) => {
const touch = new Touch( id, point, event );
this.addPointer( touch );
this.downEvent( touch, event, false );
}
} );

A similar pattern is being used in SimpleDragHandler:

// @private
this.dragStartedEmitter = new Emitter( {
tandem: options.tandem.createTandem( 'dragStartedEmitter' ),
// TODO: use of both of these is redundant, and should get fixed with https://github.com/phetsims/axon/issues/194
argumentTypes: [ { valueType: Vector2 }, { isValidValue: function( value ) { return value === null || value instanceof Event; } } ],
phetioType: EmitterIO(
[ { name: 'point', type: Vector2IO, documentation: 'the position of the drag start in view coordinates' },
{ name: 'event', type: VoidIO, documentation: 'the scenery pointer Event' } ] ),
listener: function( point, event ) {
if ( this.dragging ) { return; }
sceneryLog && sceneryLog.InputListener && sceneryLog.InputListener( 'SimpleDragHandler startDrag' );
sceneryLog && sceneryLog.InputListener && sceneryLog.push();
// set a flag on the pointer so it won't pick up other nodes
event.pointer.dragging = true;
event.pointer.cursor = self.options.dragCursor;
event.pointer.addInputListener( self.dragListener, self.options.attach );
// set all of our persistent information
self.isDraggingProperty.set( true );
self.pointer = event.pointer;
self.trail = event.trail.subtrailTo( event.currentTarget, true );
self.transform = self.trail.getTransform();
self.node = event.currentTarget;
self.lastDragPoint = event.pointer.point;
self.startTransformMatrix = event.currentTarget.getMatrix().copy();
// event.domEvent may not exist for touch-to-snag
self.mouseButton = event.pointer instanceof Mouse ? event.domEvent.button : undefined;
if ( self.options.start ) {
self.options.start.call( null, event, self.trail );
}
sceneryLog && sceneryLog.InputListener && sceneryLog.pop();
}
} );

The general pattern is: when something is happening in a simulation that needs to be emitted to the PhET-iO data stream, this is best accomplished with an instrumented Emitter. Emitter now supports a listener constructor option to facilitate cases like this.

@pixelzoom
Copy link
Contributor

pixelzoom commented Jan 31, 2019

My primary objection to this pattern is that is relies on its listeners being called in the order that they are added. It relies on the first listener (its listener option value) to be called first, and listener does work that needs to be finished before other listeners are notified. In general, relying on the order of listeners in the Observer pattern is a code smell and an anti-pattern. And when you need to rely on order, that is generally indicative of a design flaw - which I think is the case here.

Another code smell is that, in the use cases where this pattern is proposed, listener is not optional. This suggests that it should be a required parameter of a new class (not Emitter, let's refer to it as DataStreamTask), that leverages the data stream features of Emitter, either through inheritance or composition. Or perhaps Emitter's data stream features should be factored out into a new base class that is shared by Emitter and DataStreamTask. In any case, what's proposed here falls under the category of "clever", but it's using Emitter in an odd way, and adds unnecessary features/code to Emitter.

@samreid
Copy link
Member Author

samreid commented Jan 31, 2019

My primary objection to this pattern is that is relies on its listeners being called in the order that they are added.

Consider the following hypothetical examples:

class EmitterA {
  constructor( specialListener ) {
    this.listeners = [ specialListener ]; // @private
  }

  emit() {
    this.listeners.forEach( listener => listener() );
  }

  addListener( listener ) {
    this.listeners.push( listener );
  }
}

class EmitterB {
  constructor( specialListener ) {
    this.specialListener = specialListener; // @private
    this.listeners = []; // @private
  }

  emit() {
    this.specialListener();
    this.listeners.forEach( listener => listener() );
  }

  addListener( listener ) {
    this.listeners.push( listener );
  }
}

Are you saying that EmitterA has a code-smell and anti-pattern and that EmitterB doesn't? If so, can you please explain why? I'm not trying to pick a fight, just trying to understand the root issue here.

And when you need to rely on order, that is generally indicative of a design flaw - which I think is the case here.

I would like clarification on this problem. For instance, let's say that I wanted to add another listener to this.touchStartedEmitter from the issue description. That could be done so like this:

input.touchStartedEmitter.addListener( ( id, point, event ) => console.log( `touch started at ${point.x},${point.y}!` );

Are you saying that most listeners would rely on addPointer and downEvent being called before the listener being called? Shouldn't we strive to write listeners that don't depend on that? Or are you saying that will be difficult?

Another code smell is that, in the use cases where this pattern is proposed, listener is not optional.

Are you picturing something like this?

  class DataStreamTask extends Emitter {

    /**
     * @param {function} listener
     * @param {Object} [options]
     */
    constructor( listener, options ) {
      super();
      this.listener = listener;
    }

    emit() {
      this.listener();
      super.emit();
    }
  }

@pixelzoom
Copy link
Contributor

Are you saying that EmitterA has a code-smell and anti-pattern and that EmitterB doesn't? If so, can you please explain why?

Writing code that relies on this.listeners.forEach( listener => listener() ), which is present in both EmitterA and EmitterB, is the anti-pattern. EmitterB's redeeming quality is that it doesn't put specialListener into the same data structure as the other listeners, so changing the data structure or iterator doesn't affect when it's notified.

Are you saying that most listeners would rely on addPointer and downEvent being called before the listener being called? Shouldn't we strive to write listeners that don't depend on that? Or are you saying that will be difficult?

I'm saying that listeners should not depend on the order that listeners are called.

Are you picturing something like this?

Yes, if there's one task that absolutely needs to be done before listeners are notified.

@jonathanolson
Copy link
Contributor

I'm saying that listeners should not depend on the order that listeners are called.

That sounds nice in the general case, but I don't think we've held up to that so far. If we randomized the emitter listener call order, I wouldn't be surprised if a number of sims break.

If it's important to avoid that assumption, should we ensure sim code works regardless of the order? (Or is this something we could consider relaxing)?

@samreid
Copy link
Member Author

samreid commented Jan 31, 2019

I looked briefly for occurrences where the listener order matters (for cases using this pattern) and couldn't find any. I provided a synthetic example in the middle of #928 (comment) but it wasn't acknowledged. It is not clear that the Emitter(listener) pattern means the given listener must go first.

@pixelzoom
Copy link
Contributor

@jonathanolson asked:

... (Or is this something we could consider relaxing)?

This documentation (which deserves discussion) was recently added to Emitter, similar in Property. Nothing similar in Node.

    /**
     * Adds a listener.  When emitting, the listeners are called in the order they were added.
     * @param {function} listener
     * @public
     */
    addListener( listener ) {

@pixelzoom
Copy link
Contributor

@samreid said:

... I provided a synthetic example in the middle of #928 (comment) but it wasn't acknowledged.

I replied in #928 (comment):

I'm saying that listeners should not depend on the order that listeners are called.

@samreid
Copy link
Member Author

samreid commented Jan 31, 2019

Can you point out an example using this pattern where the listener order matters? Or is this conversation about hypothetical future usages?

@pixelzoom
Copy link
Contributor

Sim stepSimulationEmitter is an example where you're relying on order of listeners, and expecting that the first listener will perform the step.

@samreid
Copy link
Member Author

samreid commented Jan 31, 2019

What other listener is relying on the stepSimulationEmitter.listener going first?

@pixelzoom
Copy link
Contributor

??

stepSimulationEmitter is public, and it notifies listeners that the sim has been stepped.

@samreid
Copy link
Member Author

samreid commented Jan 31, 2019

Say Profiler is rewritten to use stepSimulationEmitter for profiling, then it doesn't matter whether it is called before or after the other listener. stepSimulationEmitter doesn't claim anywhere in its documentation that it will be called after the other work done in Sim. Is that important?

@pixelzoom
Copy link
Contributor

So you're saying `stepSimulationEmitter notifies listeners when the simulation has been stepped" is a false statement? This is baffling me.

@samreid
Copy link
Member Author

samreid commented Jan 31, 2019

"is being stepped" vs "has been stepped"?

@pixelzoom
Copy link
Contributor

pixelzoom commented Jan 31, 2019

Which brings up another smell.. stepSimulationEmitter is supposed to notify listeners that the sim has been (or is being, if you prefer) stepped. And this pattern gives the emitter the additional responsibility of actually doing the step.

@samreid
Copy link
Member Author

samreid commented Jan 31, 2019

I wonder if one reason it made sense for PhET-iO to move the "work" of the Emitters to the first listener, is that the "work" is supposed to be called during playback.

@samreid
Copy link
Member Author

samreid commented Jan 31, 2019

After discussion, we concluded we would like to add before and after args to Emitter which can be used to make callbacks before or after the listeners. Cases like those in the top comment will be rewritten to use before. @zepumph is creating an issue to scramble listener order during testing.

@zepumph
Copy link
Member

zepumph commented Jan 31, 2019

Note we should also revert comments like phetsims/axon@1239bb6 as part of this work.

@pixelzoom
Copy link
Contributor

I am still very much not on board with this entire approach. I still think it's a fundamental mistake to use Emitter and listeners to specify tasks that need to be done before/after notifying other listeners. This is a convenient solution, not a good solution. That said...

I still like to hear about #928 (comment).

And I'd like to hear why it's not possible/preferable to factor the essential PhET-iO functionality out of Emitter, where it can be more generally used to wrap any task. E.g.:

// Responsible for wrapping a task so that it appear in the PhET-iO data stream.
class PhetioTask {...}

// Get PhET-iO data stream features by inheritance or composition.
class Emitter extends PhetioTask {...}
class Emitter {
   constructor(...) {
     this.task = new PhetioTask(...);  // wrap listener notification
   }
}

// Do some task, notify listeners that the task was done, do cleanup.
var doTask = new PhetioTask( ... );
var taskCompletedEmitter = new Emitter( ... );
var doCleanup = new PhetioTask(...);

updateTask.perform(...);
updateCompletedEmitter.emit(...)
cleanupTask.perform(...);

Compared to what we have now:

somethingEmitter = new Emitter( {
  first: dt => { ... }, // perform some task in the first listener
  last: dt => {...} // so some cleanup in the last listener
} );

@pixelzoom
Copy link
Contributor

pixelzoom commented Feb 11, 2019

The current implementation also makes the data stream structure look like this, where first/last tasks and notification of listeners are nested together.

startEvent
   messages related to listener0 
   messages related to notifying listeners 1...N-2
   messages related to listenerN-1
endEvent

Are listener0 and listenerN-1 in the above data stream general listeners or these special first and last listeners? How do you know by looking at the data stream? Why is it desirable to nest first/last tasks with listener notification?

Imo, there should be a clear separation between tasks and notification, and that separation should be reflected in the data stream. E.g.:

startEvent
  messages related to doing something before notifying
endEvent
startEvent 
  messages related to notifying listeners
endEvent
startEvent
  messages related to doing something after notifying
endEvent

@pixelzoom pixelzoom assigned samreid and unassigned pixelzoom Feb 11, 2019
@samreid
Copy link
Member Author

samreid commented Feb 11, 2019

To try to answer #928 (comment):

For phetioPlayback events, we must serialize the args for storage. Then for playback, we must deserialize the args and emit the same event so the playback will run properly. There must be a direct correspondence between recording the emit (and its arguments) and getting it to emit the same way during playback.

Perhaps we have settled on the current solution as a shortcut to getting things working with our existing infrastructure. Maybe there is a way it could be designed around startEmitter/listenerEmitter/endEmitter, but it's not obvious how it could be done.

I still think it's a fundamental mistake to use Emitter and listeners to specify tasks that need to be done before/after notifying other listeners.

I hear you and am interested in finding a superior solution. Not sure what it will be though given the desire for for record and playback feature.

And I'd like to hear why it's not possible/preferable to factor the essential PhET-iO functionality out of Emitter, where it can be more generally used to wrap any task.

PhetioObject already contains phetioStartEvent and phetioEndEvent and subtypes (such as Emitter) can leverage that to emit to the data stream. So I would say the PhET-iO functionality is already well factored out, but we also like that it is easy to instrument Emitter instances (as part of the instrumentation process) by simply adding a tandem to them.

The points in #928 (comment) are well-taken.

Would you like to schedule a working collaboration meeting where we can try out some ideas? That may be more efficient than @zepumph or me to try other ideas and run them past you.

@samreid
Copy link
Member Author

samreid commented Feb 11, 2019

The place where Emitter events are leveraged for playback is handlePlaybackEvent. The pseudocode is:

for (event in events)
if event is flagged for playback
  deserialize the corresponding event data
  trigger the corresponding event

Full details are in https://github.com/phetsims/phet-io-wrappers/blob/efce8697fa12b8a72483245885b84c096a54dd90/common/js/handlePlaybackEvent.js#L37-L72. I can see that our Emitter implementation is focused around facilitating this usage.

@pixelzoom
Copy link
Contributor

pixelzoom commented Feb 11, 2019

Maybe I just don't understand why data stream and playback must be in Emitter, and can't be factored out of Emitter.

But here's what I had in mind with the example in #928 (comment). Untested, for illustration only.

All of the PhET-iO stuff (data stream, playback) is moved out of Emitter and into TANDEM/Task (for lack of a better name right now):

// Copyright 2019, University of Colorado Boulder

/**
 * Wrapper that handles PhET-iO features for a task.
 * A task is some unit of work that needs to be bracketed with start/end in the PhET-iO data stream.
 *
 * @author Chris Malley (PixelZoom, Inc.)
 */
define( require => {
  'use strict';

  // modules
  const EmitterIO = require( 'AXON/EmitterIO' );
  const PhetioObject = require( 'TANDEM/PhetioObject' );
  const tandem = require( 'TANDEM/tandem' );
  const Tandem = require( 'TANDEM/Tandem' );
  const Validator = require( 'AXON/Validator' );

  // constants
  const TaskIOWithNoArgs = EmitterIO( [] );
  const EMPTY_ARRAY = [];
  assert && Object.freeze( EMPTY_ARRAY );

  class Task extends PhetioObject {

    /**
     * @param {Object} [options]
     */
    constructor( options ) {

      options = _.extend( {

        task: null, // {function|null}

        // {Array.<Object>|null} - array of "Validator Options" Objects that hold options for how to validate each
        // argument, see Validator.js for details.
        argumentTypes: EMPTY_ARRAY,

        tandem: Tandem.optional,
        phetioState: false,
        phetioType: TaskIOWithNoArgs, // subtypes can override with TaskIO([...]), see TaskIO.js

      }, options );

      if ( options.phetioPlayback ) {
        options.phetioEventMetadata = options.phetioEventMetadata || {};
        assert && assert( !options.phetioEventMetadata.hasOwnProperty( 'dataKeys' ),
          'dataKeys should be supplied by Emitter, not elsewhere' );
        options.phetioEventMetadata.dataKeys = options.phetioType.elements.map( element => element.name );
      }

      super( options );

      // @private
      this.task = options.task;

      Validator.validate( options.argumentTypes, { valueType: Array } );

      if ( assert ) {

        // Iterate through all argumentType validator options and make sure that they won't validate options on validating value
        options.argumentTypes.forEach( validatorOptions => {
          assert && assert(
            validatorOptions.validateOptionsOnValidateValue === undefined,
            'emitter sets its own validateOptionsOnValidateValue for each argument type'
          );
          validatorOptions.validateOptionsOnValidateValue = false;

          // Changing the validator options after construction indicates a logic error
          assert && Object.freeze( validatorOptions );

          // validate the options passed in to validate each emitter argument
          Validator.validateOptions( validatorOptions );
        } );

        // Changing after construction indicates a logic error
        assert && Object.freeze( options.argumentTypes );
      }

      // @private {function|false}
      this.validate = assert && function() {
        assert(
          arguments.length === options.argumentTypes.length,
          `Emitted unexpected number of args. Expected: ${options.argumentTypes.length} and received ${arguments.length}`
        );
        for ( let i = 0; i < options.argumentTypes.length; i++ ) {
          Validator.validate( arguments[ i ], options.argumentTypes[ i ] );
        }
      };
    }

    /**
     * Sets the task to be performed.
     * @param {function} task - expected parameters are based on options.argumentTypes, see constructor
     * @protected
     */
    setTask( task ) {
      this.task = task;
    }

    /**
     * Does the task.
     * @params - expected parameters are based on options.argumentTypes, see constructor
     * @public
     */
    do() {
      assert && assert( this.task, 'do requires task to be set' );

      // validate arguments
      assert && this.validate && this.validate.apply( null, arguments );

      // handle phet-io data stream for the task
      this.isPhetioInstrumented() && this.phetioStartEvent( 'do', this.getPhetioData.apply( this, arguments ) );

      // Perform the task
      this.task.apply( null, arguments );

      this.isPhetioInstrumented() && this.phetioEndEvent();
    }

    /**
     * Gets the data that will be emitted to the PhET-iO data stream, for an instrumented simulation.
     * @returns {*}
     * @private
     */
    getPhetioData() {

      // null if there are no arguments.  dataStream.js omits null values for data
      let data = null;
      if ( this.phetioType.elements.length > 0 ) {

        // Enumerate named argsObject for the data stream.
        data = {};
        for ( let i = 0; i < this.phetioType.elements.length; i++ ) {
          const element = this.phetioType.elements[ i ];
          data[ element.name ] = element.type.toStateObject( arguments[ i ] );
        }
      }
      return data;
    }
  }

  return tandem.register( 'Task', Task );
} );

Emitter is responsible only for managing and notifying listeners. PhET-iO stuff and notifying listeners is delegated to Task when emit is called.

EDIT: This example uses inheritance; composition might be better?

// Copyright 2015-2018, University of Colorado Boulder

/**
 * Lightweight event & listener abstraction for a single event type.
 *
 * @author Sam Reid (PhET Interactive Simulations)
 */
define( require => {
  'use strict';

  // modules
  const axon = require( 'AXON/axon' );
  const EmitterIO = require( 'AXON/EmitterIO' );
  const Tandem = require( 'TANDEM/Tandem' );
  const Task = require( 'TANDEM/Task' );

  // constants
  const EmitterIOWithNoArgs = EmitterIO( [] );

  /**
   * @param {Object} [options]
   */
  class Emitter extends Task {

    constructor( options ) {

      options = _.extend( {
        phetioType: EmitterIOWithNoArgs // subtypes can override with EmitterIO([...]), see EmitterIO.js
      }, options );

      super( options );

      // @private {function[]} - the listeners that will be called on emit
      this.listeners = [];

      // @private {function[][]} - during emit() keep track of which listeners should receive events in order to manage
      //                         - removal of listeners during emit()
      this.activeListenersStack = [];

      // Task to be performed when emit is called.
      this.setTask( this.notifyListeners.bind( this ) );
    }

    /**
     * Dispose an Emitter that is no longer used.  Like Property.dispose, this method checks that there are no leaked
     * listeners.
     */
    dispose() {
      this.listeners.length = 0; // See https://github.com/phetsims/axon/issues/124
      super.dispose( this );
    }

    /**
     * Emits a single event.  This method is called many times in a simulation and must be well-optimized.  Listeners
     * are notified in the order they were added via addListener, though it is poor practice to rely on the order
     * of listener notifications.
     * @params - expected parameters are based on options.argumentTypes, see constructor
     * @public
     */
    emit() {
      this.do.apply( null, arguments );
    }

    /**
     * Notifies listeners.
     * @params - expected parameters are based on options.argumentTypes, see constructor
     * @private
     */
    notifyListeners() {

      // Notify wired-up listeners, if any
      if ( this.listeners.length > 0 ) {
        this.activeListenersStack.push( this.listeners );

        // Notify listeners--note the activeListenersStack could change as listeners are called, so we do this by index
        const lastEntry = this.activeListenersStack.length - 1;
        for ( let i = 0; i < this.activeListenersStack[ lastEntry ].length; i++ ) {
          this.activeListenersStack[ lastEntry ][ i ].apply( null, arguments );
        }

        this.activeListenersStack.pop();
      }
    }

    // Unchanged:
    // addListener, removeListener, removeAllListeners, defendListeners,
    // emit1, emit2, emit3, hasListener, hasListeners, getListenerCount
  }

  return axon.register( 'Emitter', Emitter );
} );

@ariel-phet
Copy link

Considering @pixelzoom objections to this approach, I think this is a decision (if we go down the path) that should have @kathy-phet in the loop to make the call. Feel free to continue discussion in this issue and if a consensus is reached, great...but if not, I think we need to bring this issue up at an iO meeting so implications are fully understood as best as possible.

@samreid
Copy link
Member Author

samreid commented Feb 11, 2019

I'd love to hear @zepumph and @jonathanolson thoughts on the proposal in #928 (comment).

@samreid samreid assigned jonathanolson and unassigned samreid Feb 11, 2019
@pixelzoom pixelzoom removed their assignment Feb 12, 2019
@jonathanolson
Copy link
Contributor

The structuring separation sounds good to me.

@jonathanolson jonathanolson removed their assignment Feb 19, 2019
@samreid samreid self-assigned this Feb 19, 2019
@zepumph
Copy link
Member

zepumph commented Feb 19, 2019

I think I understand the separation of the two files. It seems like Task is handling validation and phet-io, and Emitter is handling listeners.

I don't really see Task as reusable code. It seems to me to be pretty dependent on the patterns we use for Emitter. Thus it is confusing to me to have these two files, which I see as linked, in two different repos.

@samreid if we went with the above strategy, do you foresee us using Task as some replacements for emitters we currently use? By converting these from Emitter to Taks, we would be saying "you can't add listeners to these, these are just tasks that we want to wrap a phet-io event around." I'm not sure that I like having two different ways to do the same thing, and I think many spots where we added emitters for phet-io would benefit from being Emitter and not Task.

Also note that I think it is confusing for validators (currently argumentTypes) option to be in Task. The validation api has moved away from the Emitter, making it harder to understand the Emitter API. This is a small piece, but it seems like a large reasoning behind the separation is to avoid so much complexity in Emitter. I think it is worth noting where I feel like complexity was added in this pattern.

@pixelzoom
Copy link
Contributor

2/21/19 dev meeting:

CM summarized pros/cons.

AP asked for everyone to weigh in:

  • JB slightly bothered by using Emitter
  • DB agrees with abstracting out
  • JO prefers factoring out, sees only upsides, would like to hear downsides
  • CK agrees with abstracting out
  • JG same as JO, would like to hear arguments against
  • MB adding an abstract makes sense, but also current approach is working, hopefully not too much overhead
  • SR
  • MK

Consensus: Don't want first/last options for Emitter.

AP proposal: Make a separate issue to explore factoring out PhET-iO data stream features. CM will create issue and strawman, hash out with MK and SR, bring back to team. CM, SR, MK will set up a time to discuss.

@pixelzoom
Copy link
Contributor

Factoring out PhET-iO data stream responsibilities from Emitter will be investigated in phetsims/axon#222.

Closing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

6 participants