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

Add helper libraries to App Lab #24732

Merged
merged 11 commits into from Sep 17, 2018
Merged

Add helper libraries to App Lab #24732

merged 11 commits into from Sep 17, 2018

Conversation

joshlory
Copy link
Contributor

@joshlory joshlory commented Sep 11, 2018

Make it possible to load helper libraries on App Lab levels:

image

Bias the race condition in promise rejection, so it always resets `isCapturePending` after the next App Lab tick. Relying on async Promise resolution order is super fragile, especially when we mock setTimeout with `sinon.useFakeTimers`.
Copy link
Contributor

@islemaster islemaster left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've got some questions and some possible cleanup that could be deferred to a future PR.

return Promise.resolve();
}

return new Promise((resolve, error) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I prefer the conventional resolve, reject parameter names, mostly because reject is definitely a verb while error could be a noun. IMO it also reads better in error-handling cases, as when wrapping a node-style callback in a promise.

new Promise((resolve, reject) => {
  asyncWork((err, result) => {
    if (err) {
      reject(err); // A little clearer than `error(err)`
    }
    resolve(result);
  });
});

I could be convinced that in this case, where you're passing it directly as the error handler, onError would be a better name than reject.

@@ -3000,6 +3004,31 @@ StudioApp.prototype.showRateLimitAlert = function () {
});
};

let libraryPreload;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If libraries is on the instance, it seems like the libraryPreload promise should also live on the instance instead of in a static context.

error,
});
});
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you consider writing these methods with memoize and fetch and async/await? (no promises I got all the below syntax right)

StudioApp.prototype.loadLibraries_ = _.memoize(function (helperLibraryNames = []) {
  return Promise.all(helperLibraryNames.map(name => this.loadLibrary_(name)));
});

StudioApp.prototype.loadLibrary_ = _.memoize(async function (name) {
  const response = await fetch(`/libraries/${name}`)
  this.libraries[name] = await response.text();
});

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool, TIL about _.memoize! Good call using fetch over $.ajax, I need to start getting in that habit.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1, TIL about _.memoize! Question for either of you: what is the benefit of fetch over $.ajax?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fetch is (almost) standard. Although not part of ECMAScript, the Fetch spec is a WHATWG Web Platform API "Living Standard," has been widely adopted by browsers and has an excellent, stable polyfill for the older browsers we support including IE11. IMO standardized features tend to get the best support and tooling long-term - although jQuery might be an exception to this rule!

Fetch uses promises by default. IMO this makes it compact and expressive, especially when used in combination with newer ES features like async/await.

Fetch rethinks network activity. Some of the pain involved in switching to fetch happens because it comes with some paradigm shifts. For example, it encourages you to think about CORS up front. It also provides Request and Response interfaces to enable standardized caching or mocking of network activity. MDN gives a pretty good overview of this.

Fetch isn't jQuery. This is a personal preference. jQuery is an oddball in our build (still included via ruby gem in production but via yarn in tests) and we've moved away from jQuery as an idiom in general. $.ajax is one of the last things we're using all over the place. Long-term, this may have implications for build time / artifact size.

That's not to say it's perfect - in fact, we should probably be wrapping fetch with our own, nicer interface or a different helper library. Here are a couple of couterarguments: Why I won’t be using Fetch API in my apps, Why I still use XHR instead of the Fetch API.

});

if (IN_UNIT_TEST) {
return loader.catch(() => {});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's this for?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm following the pattern in commit 914ee28. When I did this in Game Lab, I was getting random karma:integration test failures when the pending promise would reject after the test finished, when another test was running.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I remember Brent doing some work to deal with state leaking across test runs... but that's all I remember :-/ I'll see if I can dig up any alternate solution.

please correct me if I'm wrong, but it seems like the downside here is that a legit error inside the call to loadLibraries_ would not be caught by this test. on the plus side... the tests are passing, and still succeed in testing everything outside of this block.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh man, debugging integration test issues... I'd like to say any tests hitting this code should stub these requests, but in practice that's not going to happen anytime soon. This seems like a reasonable bit of tech debt to trade for getting this feature up and running now. A comment about what failure mode we're avoiding would be nice though.

@@ -404,7 +403,8 @@ GameLab.prototype.init = function (config) {
config.initialAnimationList : this.startAnimations;
getStore().dispatch(setInitialAnimationList(initialAnimationList));

const loader = this.loadLibraries_().then(() => ReactDOM.render((
this.loadValidationCodeIfNeeded_();
const loader = this.studioApp_.loadLibraries_(this.level.helperLibraries).then(() => ReactDOM.render((
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm... I know this isn't a change, but why does our initial render have to wait until the helper libraries are loaded, if we don't need them until we run the program?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've been looking for a place to run async code, and guarantee it has completed before a user can run the program. Do you know of the best place to do this?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A couple of solutions come to mind.

  1. The actual run-button-click could depend on an asynchronous getLibraries step, in a lazy-load sense, so that the first run would be slow but subsequent runs would be fast. Then add a fire-and-forget preload step so that the first run is fast unless they manage to hit run before the preload finishes. I think this would work seamlessly on a share page.

  2. We could actually disable the run button until the preload finishes, so we're doing the initial render immediately but not allowing the student to try running until the resources are available. This might require some special handling on the share page.

Copy link
Contributor Author

@joshlory joshlory Sep 12, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do we do it for preloading the animation assets today?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That happens in a fairly p5.play-specific way. Here's an outline - I won't claim this is an ideal setup though. 😁

Gamelab init() dispatches setInitialAnimationList when the project is loaded.

const initialAnimationList =
(config.initialAnimationList && !config.embed && !config.hasContainedLevels) ?
config.initialAnimationList : this.startAnimations;
getStore().dispatch(setInitialAnimationList(initialAnimationList));

setInitialAnimationList in the redux module does a bunch of data-cleaning but eventually calls loadAnimationFromSource for each animation:

return dispatch => {
dispatch({
type: SET_INITIAL_ANIMATION_LIST,
animationList: serializedAnimationList
});
dispatch(selectAnimation(serializedAnimationList.orderedKeys[0] || ''));
serializedAnimationList.orderedKeys.forEach(key => {
dispatch(loadAnimationFromSource(key));
});
};

You've probably guessed, loadAnimationFromSource triggers async loads for all the animation assets into memory, and sets loadedFromSource = true for each of them when complete.

Animations loaded from the library or created by the student are similarly persisted in-memory in redux.

Later, when the user clicks "Run..."

onP5Preload is called while p5 is in the preload phase, after the interpreter has started. It increments (and later decrements) a preload counter within p5 that defers setup() (both student-code and our own) until preload steps are complete.

GameLab.prototype.onP5Preload = function () {
Promise.all([
this.preloadAnimations_(this.level.pauseAnimationsByDefault),
this.runPreloadEventHandler_()
]).then(() => {
this.gameLabP5.notifyPreloadPhaseComplete();
});
return false;
};

preloadAnimations_ waits for animations to be loaded into memory and ready to use, then passes those animations to P5 to be loaded into the engine as animations. It returns a promise which resolves once animations are all in memory in the redux store and we've started loading them into P5. However, loading to P5 is also an async process. It has its own internal effect on the P5 preloadCount, so we don't need to track it here.

GameLab.prototype.preloadAnimations_ = function (pauseAnimationsByDefault) {
let store = getStore();
return new Promise(resolve => {
if (this.areAnimationsReady_()) {
resolve();
} else {
// Watch store changes until all the animations are ready.
const unsubscribe = store.subscribe(() => {
if (this.areAnimationsReady_()) {
unsubscribe();
resolve();
}
});
}
}).then(() => {
// Animations are ready - send them to p5 to be loaded into the engine.
return this.gameLabP5.preloadAnimations(
store.getState().animationList,
pauseAnimationsByDefault);
});
};

preloadAnimations_ doesn't actually start the preload, it just checks areAnimationsReady_ which in turn asks the redux store about each animation:

GameLab.prototype.areAnimationsReady_ = function () {
const animationList = getStore().getState().animationList;
return animationList.orderedKeys.every(key => animationList.propsByKey[key].loadedFromSource);
};

isCapturePending = false;
setTimeout(() => {
isCapturePending = false;
}, 100);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setTimeout inside a promise chain feels a little like an antipattern. What exactly are we waiting for here? @davidsbailey might be able to suggest an alternative.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I have a better solution — see commit 3d38c67.

\/
%a.select_none{href: '#'} none
(shift-click or cmd-click to select multiple).
= f.select :helper_libraries, (Library.distinct.pluck(:name) + (@level.helper_libraries || [])).uniq.sort, {}, {multiple: true}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this field be extracted to a partial instead of duplicated across the App Lab and Game Lab editors?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not super worried about this b/c I think we'll likely move this field up higher (into a shared view) in the future. I'd imagine more level types will eventually have Helper Library code.

Ensure the promise doesn't reject in the same (simulated) tick as the current Applab.tickCount.
@joshlory joshlory changed the title Move helper library load to StudioApp Add helper libraries to App Lab Sep 15, 2018
# Conflicts:
#	apps/src/StudioApp.js
#	apps/src/gamelab/GameLab.js
#	apps/test/unit/gamelab/GameLabTest.js
@joshlory
Copy link
Contributor Author

@islemaster PTAL!

@joshlory joshlory merged commit 1c1948b into staging Sep 17, 2018
@joshlory joshlory deleted the move-libraries-to-studio-app branch September 17, 2018 20:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants