Closed samreid closed 3 years ago
Question: What should the API for uninstrumented PhetioObjects be? Should it be the same as for instrumented PhetioObjects running in PhET brand?
We moved fields into the initialize function, but we now need to answer the above questions to optimize how much we can strip out in phet brand and for all uninstrumented PhetioObjects.
To me, the most important, must-haves seem to be tandem
, and isPhetioInstrumented()
.
But @samreid and I were also looking at some common code components that used : options.phetio
, for passing metadata to sub components, and were wondering if it would be smoother to use this.phetio
... We will keep looking at this.
In the above commit, @samreid and I took this one step farther to not set PhetioObject keys in PhET brand. It involved only a couple of work arounds in PhetioDynamicElementContainer and Node. We also made exposing public fields more explicit with assertions making sure that you don't get an undefined when accessing fields in PhET brand.
Marking for review to check back on CT.
Nothing on CT. Closing
Working copy changes:
" instead of newlines const PHET_IO_DOCUMENTATION_VALIDATOR = { valueType: 'string', isValidValue: doc => doc.indexOf( '\n' ) === -1 }; const PHET_IO_EVENT_TYPE_VALIDATOR = { valueType: EventType }; const OBJECT_VALIDATOR = { valueType: [ Object, null ] }; // When an event is suppressed from the data stream, we keep track of it with this token. const SKIPPING_MESSAGE = -1; const DEFAULTS = { // Subtypes can use `Tandem.tandemRequired` to require a named tandem passed in tandem: Tandem.OPTIONAL, // Defines API methods, events and serialization phetioType: IOType.ObjectIO, // {string} Useful notes about an instrumented PhetioObject, shown in the PhET-iO Studio Wrapper. It's an html // string, so "
" tags are required instead of "\n" characters for proper rendering in Studio phetioDocumentation: '', // When true, includes the PhetioObject in the PhET-iO state (not automatically recursive, must be specified for // children explicitly) phetioState: true, // This option controls how PhET-iO wrappers can interface with this PhetioObject. Predominately this occurs via // public methods defined on this PhetioObject's phetioType, in which some method are not executable when this flag // is true. See `ObjectIO.methods` for further documentation, especially regarding `invocableForReadOnlyElements`. phetioReadOnly: false, // Category of event type, can be overridden in phetioStartEvent options phetioEventType: EventType.MODEL, // High frequency events such as mouse moves can be omitted from data stream, see ?phetioEmitHighFrequencyEvents // and Client.launchSim option phetioHighFrequency: false, // When true, emits events for data streams for playback, see handlePlaybackEvent.js phetioPlayback: false, // When true, Studio is allowed to create a control for this PhetioObject (if it knows how) phetioStudioControl: true, // When true, this is categorized as an important "featured" element in Studio. phetioFeatured: false, // {Object|null} optional - delivered with each event, if specified. phetioPlayback is appended here, if true. // Note: unlike other options, this option can be mutated downstream, and hence should be created newly for each instance. phetioEventMetadata: null, // {boolean} optional - indicates that an object may or may not have been created. Applies recursively automatically // and should only be set manually on the root dynamic element. Dynamic archetypes will have this overwritten to // false even if explicitly provided as true, as archetypes cannot be dynamic. phetioDynamicElement: false }; /** * @param {Object} [options] * @constructor */ function PhetioObject( options ) { // @public (read-only) {Tandem} - assigned in initializePhetioObject - see docs at DEFAULTS declaration this.tandem = DEFAULTS.tandem; // @private {boolean} - track whether the object has been initialized. This is necessary because initialization // can happen in the constructor or in a subsequent call to initializePhetioObject (to support scenery Node) this.phetioObjectInitialized = false; // @public (read-only) {boolean} - has it been disposed? this.isDisposed = false; if ( options ) { this.initializePhetioObject( {}, options ); } if ( assert ) { // Wrap the prototype dispose method with a check. NOTE: We will not catch devious cases where the dispose() is // overridden after the Node constructor (which may happen). const protoDispose = this.dispose; this.dispose = () => { assert && assert( !this.isDisposed, 'This PhetioObject has already been disposed, and cannot be disposed again' ); protoDispose.call( this ); assert && assert( this.isDisposed, 'PhetioObject.dispose() call is missing from an overridden dispose method' ); }; } } /** * Determine if any of the options keys are intended for PhetioObject. Semantically equivalent to * _.intersection( _.keys( options ), _.keys( DEFAULTS) ).length>0 but implemented imperatively to avoid memory or * performance issues. Also handles options.tandem differently. * @param {Object} [options] * @returns {boolean} */ const specifiesNonTandemPhetioObjectKey = options => { for ( const key in options ) { if ( key !== 'tandem' && options.hasOwnProperty( key ) && DEFAULTS.hasOwnProperty( key ) ) { return true; } } return false; }; // Since PhetioObject is extended with inherit (e.g., SCENERY/Node), this cannot be an ES6 class inherit( Object, PhetioObject, { /** * Like SCENERY/Node, PhetioObject can be configured during construction or later with a mutate call. * Noop if provided config keys don't intersect with any key in DEFAULTS; baseOptions are ignored for this calculation. * * @param {Object} baseOptions * @param {Object} config * @protected */ initializePhetioObject: function( baseOptions, config ) { assert && assert( config, 'initializePhetioObject must be called with config' ); // call before we exit early to support logging unsupplied Tandems. config.tandem && Tandem.onMissingTandem( config.tandem ); // Make sure that required tandems are supplied if ( Tandem.VALIDATION && config.tandem && config.tandem.required ) { assert && assert( config.tandem.supplied, 'required tandems must be supplied' ); } // The presence of `tandem` indicates if this PhetioObject can be initialized. If not yet initialized, perhaps // it will be initialized later on, as in Node.mutate(). if ( !( config.tandem && config.tandem.supplied ) ) { assert && !config.tandem && assert( !specifiesNonTandemPhetioObjectKey( config ), 'only specify metadata when providing a Tandem' ); if ( config.tandem ) { this.tandem = config.tandem; } return; } // assert this after the `specifiesPhetioObjectKey check to support something like: // `new Node( {tandem: tandem}).mutate({})` assert && assert( !this.phetioObjectInitialized, 'cannot initialize twice' ); // Guard validation on assert to avoid calling a large number of no-ops when assertions are disabled, see https://github.com/phetsims/tandem/issues/200 assert && validate( config.tandem, { valueType: Tandem } ); config = merge( {}, DEFAULTS, baseOptions, config ); // validate config before assigning to properties assert && validate( config.phetioType, IO_TYPE_VALIDATOR ); assert && validate( config.phetioState, BOOLEAN_VALIDATOR ); assert && validate( config.phetioReadOnly, BOOLEAN_VALIDATOR ); assert && validate( config.phetioEventType, PHET_IO_EVENT_TYPE_VALIDATOR ); assert && validate( config.phetioDocumentation, PHET_IO_DOCUMENTATION_VALIDATOR ); assert && validate( config.phetioHighFrequency, BOOLEAN_VALIDATOR ); assert && validate( config.phetioPlayback, BOOLEAN_VALIDATOR ); assert && validate( config.phetioStudioControl, BOOLEAN_VALIDATOR ); assert && validate( config.phetioFeatured, BOOLEAN_VALIDATOR ); assert && validate( config.phetioEventMetadata, OBJECT_VALIDATOR ); assert && validate( config.phetioDynamicElement, BOOLEAN_VALIDATOR ); assert && assert( this.linkedElements !== null, 'this means addLinkedElement was called before instrumentation of this PhetioObject' ); // @public (phetioEngine) {Object|null} - only non null with phet.preloads.phetio.queryParameters.phetioPrintAPI enabled this.phetioBaselineMetadata = null; // @public {boolean} optional - Indicates that an object is a archetype for a dynamic class. Settable only by // PhetioEngine and by classes that create dynamic elements when creating their archetype (like PhetioGroup) through // PhetioObject.markDynamicElementArchetype(). // if true, items will be excluded from phetioState. This applies recursively automatically. this.phetioIsArchetype = false; // This block is associated with validating the baseline api and filling in metadata specified in the elements // overrides API file. Even when validation is not enabled, overrides should still be applied. if ( PHET_IO_ENABLED && config.tandem.supplied ) { // Store the full baseline for usage in validation or for usage in studio. Do this before applying overrides. The // baseline is created when a sim is run with assertions to assist in phetioAPIValidation. However, even when // assertions are disabled, some wrappers such as studio need to generate the baseline anyway. if ( phetioAPIValidation.enabled || phet.preloads.phetio.queryParameters.studio ) { // not all metadata are passed through via config, so store baseline for these additional properties this.phetioBaselineMetadata = this.getMetadata( merge( { phetioIsArchetype: this.phetioIsArchetype // TODO: this.phetioIsArchetype not defined yet }, config ) ); } // If not a deprecated dynamic element // TODO: Remove '~' check once TANDEM/Tandem.GroupTandem usages have been replaced, see https://github.com/phetsims/tandem/issues/87 if ( config.tandem.phetioID.indexOf( '~' ) === -1 ) { // Dynamic elements should compare to their "archetypal" counterparts. For example, this means that a Particle // in a PhetioGroup will take its overrides from the PhetioGroup archetype. const archetypalPhetioID = config.tandem.getArchetypalPhetioID(); // Overrides are only defined for simulations, not for unit tests. See https://github.com/phetsims/phet-io/issues/1461 // Patch in the desired values from overrides, if any. if ( window.phet.preloads.phetio.phetioElementsOverrides ) { const overrides = window.phet.preloads.phetio.phetioElementsOverrides[ archetypalPhetioID ]; if ( overrides ) { // No need to make a new object, since this "config" variable was created in the previous merge call above. config = merge( config, overrides ); } } } } // @public (read-only) {Tandem} - assigned in initializePhetioObject - see docs at DEFAULTS declaration this.tandem = config.tandem; // @public (read-only) {IOType} - assigned in initializePhetioObject - see docs at DEFAULTS declaration this.phetioType = config.phetioType; // @public (read-only) {boolean} - assigned in initializePhetioObject - see docs at DEFAULTS declaration this.phetioState = config.phetioState; // @public (read-only) {boolean} - assigned in initializePhetioObject - see docs at DEFAULTS declaration this.phetioReadOnly = config.phetioReadOnly; // @public (read-only) {string} - assigned in initializePhetioObject - see docs at DEFAULTS declaration this.phetioDocumentation = config.phetioDocumentation; // @private {EventType} - see docs at DEFAULTS declaration this.phetioEventType = config.phetioEventType; // @private {boolean} - see docs at DEFAULTS declaration this.phetioHighFrequency = config.phetioHighFrequency; // @private {boolean} - see docs at DEFAULTS declaration this.phetioPlayback = config.phetioPlayback; // @private {boolean} - see docs at DEFAULTS declaration this.phetioStudioControl = config.phetioStudioControl; // @public (PhetioEngine) {boolean} - see docs at DEFAULTS declaration - in order to recursively pass this value to // children, the setPhetioDynamicElement() function must be used instead of setting this attribute directly this.phetioDynamicElement = config.phetioDynamicElement; // @public (read-only) {boolean} - see docs at DEFAULTS declaration this.phetioFeatured = config.phetioFeatured; // @private {Object|null} this.phetioEventMetadata = config.phetioEventMetadata; // @private {string|null} - for phetioDynamicElements, the corresponding phetioID for the element in the archetype subtree this.phetioArchetypePhetioID = null; // @private {LinkedElement[]|null} - keep track of LinkedElements for disposal. Null out to support asserting on // edge error cases, see this.addLinkedElement() this.linkedElements = []; // @public (phet-io) set to true when this PhetioObject has been sent over to the parent. this.phetioNotifiedObjectCreated = false; // @private {number|null} - tracks the indices of started messages so that dataStream can check that ends match starts this.phetioMessageStack = []; // Make sure playback shows in the phetioEventMetadata if ( this.phetioPlayback ) { this.phetioEventMetadata = this.phetioEventMetadata || {}; assert && assert( !this.phetioEventMetadata.hasOwnProperty( 'playback' ), 'phetioEventMetadata.playback should not already exist' ); this.phetioEventMetadata.playback = true; } if ( PHET_IO_ENABLED && this.isPhetioInstrumented() ) { assert && Tandem.VALIDATION && assert( !this.phetioType.uninstrumented, 'cannot instantiate a phetioType that should not be instrumented' ); } // Alert that this PhetioObject is ready for cross-frame communication (thus becoming a "PhET-iO element" on the wrapper side. this.tandem.addPhetioObject( this ); this.phetioObjectInitialized = true; }, /** * Start an event for the nested PhET-iO data stream. * * @param {string} event - the name of the event * @param {Object} [options] * @public */ phetioStartEvent: function( event, options ) { if ( PHET_IO_ENABLED && this.isPhetioInstrumented() ) { // only one or the other can be provided assert && assertMutuallyExclusiveOptions( options, [ 'data' ], [ 'getData' ] ); options = merge( { // {Object|null} - the data data: null, // {function():Object|null} - function that, when called get's the data. getData: null }, options ); assert && assert( this.phetioObjectInitialized, 'phetioObject should be initialized' ); assert && assert( typeof event === 'string' ); assert && options.data && assert( typeof options.data === 'object' ); assert && options.getData && assert( typeof options.getData === 'function' ); assert && assert( arguments.length === 1 || arguments.length === 2, 'Prevent usage of incorrect signature' ); // Opt out of certain events if queryParameter override is provided. Even for a low frequency data stream, high // frequency events can still be emitted when they have a low frequency ancestor. const skipHighFrequencyEvent = this.phetioHighFrequency && _.hasIn( window, 'phet.preloads.phetio.queryParameters' ) && !window.phet.preloads.phetio.queryParameters.phetioEmitHighFrequencyEvents && !phet.phetio.dataStream.isEmittingLowFrequencyEvent(); if ( skipHighFrequencyEvent || this.phetioEventType === EventType.OPT_OUT ) { this.phetioMessageStack.push( SKIPPING_MESSAGE ); return; } // Only get the args if we are actually going to send the event. const data = options.getData ? options.getData() : options.data; this.phetioMessageStack.push( phet.phetio.dataStream.start( this.phetioEventType, this.tandem.phetioID, this.phetioType, event, data, this.phetioEventMetadata, this.phetioHighFrequency ) ); // To support PhET-iO playback, any potential playback events downstream of this playback event must be marked as // non playback events. This is to prevent the PhET-iO playback engine from repeating those events. See // https://github.com/phetsims/phet-io/issues/1693 this.phetioPlayback && phet.phetio.dataStream.pushNonPlaybackable(); } }, /** * End an event on the nested PhET-iO data stream. It this object was disposed or dataStream.start was not called, * this is a no-op. * @public */ phetioEndEvent: function() { if ( PHET_IO_ENABLED && this.isPhetioInstrumented() ) { assert && assert( this.phetioMessageStack.length > 0, 'Must have messages to pop' ); const topMessageIndex = this.phetioMessageStack.pop(); // The message was started as a high frequency event to be skipped, so the end is a no-op if ( topMessageIndex === SKIPPING_MESSAGE ) { return; } this.phetioPlayback && phet.phetio.dataStream.popNonPlaybackable(); phet.phetio.dataStream.end( topMessageIndex ); } }, /** * Set any instrumented descendants of this PhetioObject to the same value as this.phetioDynamicElement. * @private */ propagateDynamicFlagsToDescendants: function() { assert && assert( Tandem.PHET_IO_ENABLED, 'phet-io should be enabled' ); assert && assert( phet.phetio && phet.phetio.phetioEngine, 'Dynamic elements cannot be created statically before phetioEngine exists.' ); const phetioEngine = phet.phetio.phetioEngine; this.tandem.iterateDescendants( tandem => { if ( phetioEngine.hasPhetioObject( tandem.phetioID ) ) { assert && assert( this.isPhetioInstrumented() ); const phetioObject = phetioEngine.getPhetioObject( tandem.phetioID ); // Order matters here! The phetioIsArchetype needs to be first to ensure that the setPhetioDynamicElement // setter can opt out for archetypes. phetioObject.phetioIsArchetype = this.phetioIsArchetype; phetioObject.setPhetioDynamicElement( this.phetioDynamicElement ); if ( phetioObject.phetioBaselineMetadata ) { phetioObject.phetioBaselineMetadata.phetioIsArchetype = this.phetioIsArchetype; } } } ); }, /** * @param {boolean} phetioDynamicElement * @public (PhetioEngine) */ setPhetioDynamicElement( phetioDynamicElement ) { assert && assert( !this.phetioNotifiedObjectCreated, 'should not change dynamic element flags after notifying this PhetioObject\'s creation.' ); assert && assert( this.isPhetioInstrumented() ); // All archetypes are static (non-dynamic) this.phetioDynamicElement = this.phetioIsArchetype ? false : phetioDynamicElement; // For dynamic elements, indicate the corresponding archetype element so that clients like Studio can leverage // the archetype metadata. Static elements don't have archetypes. this.phetioArchetypePhetioID = phetioDynamicElement ? this.tandem.getArchetypalPhetioID() : null; // Keep the baseline metadata in sync. if ( this.phetioBaselineMetadata ) { this.phetioBaselineMetadata.phetioDynamicElement = this.phetioDynamicElement; } }, /** * Mark this PhetioObject as an archetype for dynamic elements. * @public */ markDynamicElementArchetype: function() { assert && assert( !this.phetioNotifiedObjectCreated, 'should not change dynamic element flags after notifying this PhetioObject\'s creation.' ); this.phetioIsArchetype = true; this.setPhetioDynamicElement( false ); // because archetypes aren't dynamic elements if ( this.phetioBaselineMetadata ) { this.phetioBaselineMetadata.phetioIsArchetype = this.phetioIsArchetype; } // recompute for children also, but only if phet-io is enabled Tandem.PHET_IO_ENABLED && this.propagateDynamicFlagsToDescendants(); }, /** * A PhetioObject will only be instrumented if the tandem that was passed in was "supplied". See Tandem.supplied * for more info. * @returns {boolean} * @public */ isPhetioInstrumented: function() { return this.tandem && this.tandem.supplied; }, /** * When an instrumented PhetioObject is linked with another instrumented PhetioObject, this creates a one-way * association which is rendered in Studio as a "symbolic" link or hyperlink. Many common code UI elements use this * automatically. To keep client sites simple, this has a graceful opt-out mechanism which makes this function a * no-op if either this PhetioObject or the target PhetioObject is not instrumented. * @param {PhetioObject} element - the target element. Must be instrumented for a LinkedElement to be created-- * - otherwise it gracefully opts out * @param {Object} [options] * @public */ addLinkedElement: function( element, options ) { if ( !this.isPhetioInstrumented() ) { // set this to null so that you can't addLinkedElement on an uninitialized PhetioObject and then instrument // it afterwards. this.linkedElements = null; return; } assert && assert( element instanceof PhetioObject, 'element must be of type PhetioObject' ); // In some cases, UI components need to be wired up to a private (internal) Property which should neither be // instrumented nor linked. if ( PHET_IO_ENABLED && element.isPhetioInstrumented() ) { assert && assert( Array.isArray( this.linkedElements ), 'linkedElements should be an array' ); this.linkedElements.push( new LinkedElement( element, options ) ); } }, /** * Remove all linked elements linking to the provided PhetioObject. This will dispose all removed LinkedElements. This * will be graceful, and doesn't assume or assert that the provided PhetioObject has LinkedElement(s), it will just * remove them if they are there. * @param {PhetioObject} potentiallyLinkedElement * @public */ removeLinkedElements: function( potentiallyLinkedElement ) { if ( this.isPhetioInstrumented() && this.linkedElements ) { assert && assert( potentiallyLinkedElement instanceof PhetioObject ); assert && assert( potentiallyLinkedElement.isPhetioInstrumented() ); const toRemove = this.linkedElements.filter( linkedElement => linkedElement.element === potentiallyLinkedElement ); toRemove.forEach( linkedElement => { linkedElement.dispose(); arrayRemove( this.linkedElements, linkedElement ); } ); } }, /** * Performs cleanup after the sim's construction has finished. * * @public */ onSimulationConstructionCompleted: function() { // deletes the phetioBaselineMetadata, as it's no longer needed since validation is complete. this.phetioBaselineMetadata = null; }, /** * Remove this phetioObject from PhET-iO. After disposal, this object is no longer interoperable. Also release any * other references created during its lifetime. * @public */ dispose: function() { assert && assert( !this.isDisposed, 'PhetioObject can only be disposed once' ); // In order to support the structured data stream, PhetioObjects must end the messages in the correct // sequence, without being interrupted by dispose() calls. Therefore, we do not clear out any of the state // related to the endEvent. Note this means it is acceptable (and expected) for endEvent() to be called on // disposed PhetioObjects. // // The phetioEvent stack should resolve by the next frame, so that's when we check it. assert && animationFrameTimer.runOnNextTick( () => { // Uninstrumented PhetioObjects don't have a phetioMessageStack attribute. assert && assert( !this.hasOwnProperty( 'phetioMessageStack' ) || this.phetioMessageStack.length === 0, 'phetioMessageStack should be clear' ); } ); if ( this.phetioObjectInitialized ) { this.tandem.removePhetioObject( this ); } // Dispose LinkedElements if ( this.linkedElements ) { this.linkedElements.forEach( linkedElement => linkedElement.dispose() ); this.linkedElements.length = 0; } this.isDisposed = true; }, /** * JSONifiable metadata that describes the nature of the PhetioObject. We must be able to read this * for baseline (before object fully constructed we use object) and after fully constructed * which includes overrides. * @param {Object} [object] - used to get metadata keys, can be a PhetioObject, or an options object * (see usage initializePhetioObject). If not provided, will instead use the value of "this" * @returns {Object} - metadata plucked from the passed in parameter * @public */ getMetadata: function( object ) { object = object || this; const metadata = { phetioTypeName: object.phetioType.typeName, phetioDocumentation: object.phetioDocumentation, phetioState: object.phetioState, phetioReadOnly: object.phetioReadOnly, phetioEventType: EventType.phetioType.toStateObject( object.phetioEventType ), phetioHighFrequency: object.phetioHighFrequency, phetioPlayback: object.phetioPlayback, phetioStudioControl: object.phetioStudioControl, phetioDynamicElement: object.phetioDynamicElement, phetioIsArchetype: object.phetioIsArchetype, phetioFeatured: object.phetioFeatured }; if ( object.phetioArchetypePhetioID ) { metadata.phetioArchetypePhetioID = object.phetioArchetypePhetioID; } return metadata; } }, { DEFAULT_OPTIONS: DEFAULTS, // the default options for the phet-io object // THe keys that constitute the publicly available PHET-iO metadata for the PhetioObject. METADATA_KEYS: [ 'phetioTypeName', 'phetioDocumentation', 'phetioState', 'phetioReadOnly', 'phetioEventType', 'phetioHighFrequency', 'phetioPlayback', 'phetioStudioControl', 'phetioDynamicElement', 'phetioIsArchetype', 'phetioFeatured', 'phetioArchetypePhetioID' ] } ); /** * Internal class to avoid cyclic dependencies. * @private */ class LinkedElement extends PhetioObject { /** * @param {PhetioObject} coreElement * @param {Object} [options] */ constructor( coreElement, options ) { assert && assert( !!coreElement, 'coreElement should be defined' ); assert && assert( coreElement instanceof PhetioObject, 'coreElement should be PhetioObject' ); assert && assert( coreElement.tandem, 'coreElement should have a tandem' ); options = merge( { phetioType: LinkedElementIO }, options ); // References cannot be changed by PhET-iO assert && assert( !options.hasOwnProperty( 'phetioReadOnly' ), 'phetioReadOnly set by LinkedElement' ); options.phetioReadOnly = true; // By default, this linked element's baseline value is the overridden value of the coreElement. This allows // the them to be in sync by default, but also allows the linked element to be overridden in studio. assert && assert( !options.hasOwnProperty( 'phetioFeatured' ), 'phetioFeatured set by LinkedElement' ); options.phetioFeatured = coreElement.phetioFeatured; super( options ); // @public (read-only) this.element = coreElement; } /** * LinkedElements listen to their core elements for phetioFeatured, so to avoid a dependency on overrides metadata * (when the core element's phetioFeatured is specified in the overrides file), ignore phetioFeatured for LinkedElements. * @override * @param {Object} object - used to get metadata keys, can be a PhetioObject, or an options object * (see usage initializePhetioObject) * @returns {Object} - metadata plucked from the passed in parameter * @public */ getMetadata( object ) { const phetioObjectMetadata = super.getMetadata( object ); delete phetioObjectMetadata.phetioFeatured; return phetioObjectMetadata; } } tandemNamespace.register( 'PhetioObject', PhetioObject ); export default PhetioObject; ```