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

Enable Embroider/Webpack code spliting for Wizard #23678

Closed
wants to merge 11 commits into from

Conversation

chancancode
Copy link
Contributor

@chancancode chancancode commented Sep 27, 2023

This PR includes a lot of refactors and cleanups that can stand on their own/works in the current build. For your reviewing pleasure, you can view these chunks separately:

I do recommend reviewing the commits individually, since the move/rename to gjs lost track of some of the diff


To verify, you can run this branch locally, go to any non-wizard route, inspect the loaded chunks to see that none of the wizard code is loaded (other than the stub for the route entrypoint), and then navigate to /wizard to see that it loaded an additional chunk with all the wizard code in there.


  • Move Wizard back into main app, remove Wizard addon
  • Remove Wizard-related resolver or build hacks
  • Add a app/static folder in the app, add it to staticAppPaths
  • Install and enable @embroider/router
  • Add "wizard" to splitAtRoutes

In a fully optimized Embroider app, route-based code splitting more or less Just Work™ – install @embroider/router, subclass from it, configure which routes you want to split and that's about it.

However, our app is not "fully optimized", by which I mean we are not able to turn on all the static* flags.

In Embroider, "static" means "statically analyzable". Specifically it means that all inter-dependencies between modules (files) are explicitly expressed as imports, as opposed to {{i18n ...}} magically means "look for the default export in app/helpers/i18n.js" or something even more dynamic with the resolver.

Without turning on those flags, Embroider behaves conservatively, slurps up all app files eagerly into the primary bundle/chunks. So, while you could turn on route-based code splitting, there won't be much to split.

The commits leading up to this involves a bunch of refactors and cleanups that 1) works perfectly fine in the classic build, 2) are good and useful in their own right, but also 3) re-arranged things such that most dependencies are now explicit.

With those in place, I was able to move all the wizard code into the "app/static" folder. Embrodier does not eagerly pull things from this folder into any bundle, unless something explicitly "asks" for them via imports. Conversely, thigns from this folder are not registered with the resolver and are not added to the loader.js registry.

In conjunction with route-based code splitting, we now have the ability to split out islands of on-demand functionalities from the main app bundle.

When you split a route in Embroider, it automatically creates a bundle/entrypoint with the relevant routes/templates/controllers matching that route prefix. Anything they import will be added to the bundle as well, assuming they are not already in the main app bundle, which is where the "app/static" folder comes into play.

The "app/static" folder name is not special. It is configured in ember-cli-build.js. Alternatively, we could have left everything in their normal locations, and add more fine-grained paths to the staticAppPaths array. I just thought it would be easy to manage and scale, and less error-prone to do it this way.

Note that putting things in app/static does not guarantee that it would not be part of the main app bundle. For example, if we were to add an import ... from "app/static/wizard/..."; in a main bundle file (say, app.js), then that chunk of the module graph would be pulled in. (Consider using await import(...)?)

Overtime, we can build better tooling (e.g. lint rules and babel macros to make things less repetitive) as we expand the use of this pattern, but this is a start.

@chancancode
Copy link
Contributor Author

Lint failure will be fixed by ember-template-lint/ember-template-lint#2982, or we can fix it in our own config

@NullVoxPopuli
Copy link
Contributor

Yaaaaas! Excitement!


export default {
"styling-preview": StylingPreview,
};
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Since this is the only thing left, we may want to consider getting rid of the type: "component" infrastructure and just do type: "styling-preview"?

@@ -8,7 +8,7 @@
</canvas>
</div>
<div class="wizard-container__preview homepage-preview">
<HomepagePreview @wizard={{this.wizard}} @step={{this.step}} />
<this.HomepagePreview @wizard={{this.wizard}} @step={{this.step}} />
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@davidtaylorhq this is what I mentioned on the call

@@ -19,15 +20,20 @@ export default WizardPreviewBaseComponent.extend({
draggingActive: false,
startX: 0,
scrollLeft: 0,
HomepagePreview,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@davidtaylorhq counterpart of the above

}}
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 suppose this could just use the <this.component> syntax too


get fieldClass() {
return `field-${dasherize(this.field.id)} wizard-focusable`;
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

So this is is interesting. It seems like the fields are expected to put the @fieldClass on their element, but very few actually do, and this is almost dead code. And those that do use it, does not put it on an actual "focusable" DOM element. This class is in turns used by the autoFocus code in the wizard. So, basically the "autofocus" code doesn't really do anything right now. But I kept it around (not) working exactly as it was before to let someone else tackle that after.


$(".wizard-focusable:nth-of-type(1)").focus();
});
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

See comment above: this doesn't really do anything.

Jump In: on the "ready" page, it exits the setup ("finish"), on the
last page, it saves, and if successful, go to the home page

*/
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is actually a considerable improvement over the status quo, in that the only step that we sort of hardcode is "ready". It will work just fine if that step isn't present, but if it does, it splits the wizard flow into the "required" and "optional" steps and displays the buttons correctly, without hard coding in "styling", "branding", etc. And the final step is now only "special" by way of being last, rather than hard coding "corporate".

} finally {
this.saving = false;
}
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This doesn't exactly do what you would imagine (but is ported over exactly as-is). You would think the try/catch is there to guard the await this.step.save() step failing, but that step actually never fails, because it has a catch that never re-throws the error.

What actually happens is that if an error happens in this.step.save(), it will fail to return a response value (response === undefined), and the code in @goNext is guarded with response?.blah such that it doesn't actually do anything if response is undefined.

I don't think that is how anyone is envisioning things to work 😬

Probably should make step.save re-throw, so that we never call this.args.goNext when the save fails. But I left it for someone else to tackle.

this.router.transitionTo("wizard.step", next);
} else if (response?.success) {
this.router.transitionTo("discovery.latest");
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is the counterpart of the comment above. If response is undefined, this doesn't end up doing anything

@@ -7,6 +7,7 @@ import {
query,
updateCurrentUser,
} from "discourse/tests/helpers/qunit-helpers";
import "discourse/routes/wizard";
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Because the wizard route is not guaranteed to be in the main bundle, tests that want to have it loaded needs to do this.

@@ -20,24 +20,47 @@ export default function (helpers) {
description: "Your name",
},
],
next: "styling",
next: "hello-again",
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Beefed up the test to have all five possibilities in the table

@@ -1,31 +1,31 @@
import Route from "@ember/routing/route";
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Note that this file lives in the normal location (app/routes/wizard.js). With splitAtRoute: ["wizard"], app/routes/wizard, app/routes/wizard/*, app/controllers/wizard, app/controllers/wizard/*, app/templates/wizard, app/templates/wizard/* all get split off into the lazy bundle. In an earlier iteration, I experimented with putting all of these files primarily in app/static/wizard, and added a stub to re-export them in the normal location, but that ended up being a lot of extra boilerplate IMO.


get component() {
let id = dasherize(this.args.field.id);
assert(`"${id}" is not a valid wizard component`, id in components);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We could supplement this with a resolver lookup as a fallback if necessary

} catch (error) {
for (let err of error.jqXHR.responseJSON.errors) {
this.fieldError(err.field, err.description);
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is where it doesn't re-throw (or throw a new error)

<div class="discourse-logo">
<DiscourseLogo />
</div>

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 moved the canvas into the step, which works just as well (since it's position: fixed anyway), but avoids having having access to the child route's step here somehow, which was a bit awkward

<template>
{{#if this.showCanvas}}
<WizardCanvas />
{{/if}}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We may even want to move this into WizardStep itself. It probably wan't possibly previously because the wrapping div was used for styling, but since I migrated it to glimmer component it should be no problem now

@include breakpoint("mobile-extra-large") {
order: 1;
margin-left: 0;
}
&:hover {
background-color: var(--primary-300);
}
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 don't actually think this is intentional, but I kept it here.

Previously, the "Jump In" button did not have the .primary CSS class, but it very much is and wants to be the primary action, and duplicated a bunch of styles from .primary. Now, I just added the primary class to the "Jump In" button and removed the redundant rules. This is the only difference left and it makes the hover shade a bit different than the "Next" button. To my eyes it looked off and I think we probably just want to remove this, but I left it as is for now.

@davidtaylorhq davidtaylorhq added the js-modernization Tasks related to our JS modernization project, including the Ember 4.0+ upgrade label Sep 28, 2023
chancancode added a commit to chancancode/discourse that referenced this pull request Oct 12, 2023
This is extracted from discourse#23678, look there for a longer description
chancancode added a commit to chancancode/discourse that referenced this pull request Oct 12, 2023
This is extracted from discourse#23678, look there for a longer description
chancancode added a commit to chancancode/discourse that referenced this pull request Oct 19, 2023
This is extracted from discourse#23678, look there for a longer description
@chancancode chancancode force-pushed the embroider-wizardry branch 2 times, most recently from 0a062a7 to 0f55e63 Compare October 20, 2023 04:05
davidtaylorhq pushed a commit that referenced this pull request Oct 26, 2023
This is extracted from #23678, look there for a longer description
davidtaylorhq pushed a commit that referenced this pull request Nov 23, 2023
+ native classes
+ tracked properties
- Ember.Object
- Ember.Evented
- observers
- mixins
- computed/discourseComputed

Also removes unused wizard infrastructure for warnings. It appears
that once upon on time, either the server can generate warnings,
or some client code can generate them, which requires an extra 
confirmation from the user before they can continue to the next step.

This code is not tested and appears unused and defunct. Nothing
generates such warning and the server does not serialize them.

Extracted from #23678
This code has gone through a lot of refactors and repurposing, and
things have become more complicated and confusing than they needed
to be.

At present, the Wizard goes through a few "required" steps for the
basics, then shows a "ready" screen directing the user to "jump in"
but have an option to continue on for optional screens, eventually
ending on the "corporate" screen which is the final step.

On desktop, there are two groups of buttons –

1. on the left side, it shows a back button except for the first
   screen

2. on the right side, there is a always a primary button (either
   "next" or "jump in"), and then to its left, there is sometimes
   a secondary action styled as a link (either "configure more" or
   "exit setup")

On mobile, these buttons are stacked, with the back button sitting
at the bottom, if present.

In table form:

Step        Back Button?     Primary Action      Secondary Action
------------------------------------------------------------------
First            No               Next                  N/A
------------------------------------------------------------------
...             Yes               Next                  N/A
------------------------------------------------------------------
Ready           Yes              Jump In          Configure More
------------------------------------------------------------------
...             Yes               Next              Exit Setup
------------------------------------------------------------------
Last            Yes              Jump In                N/A
------------------------------------------------------------------

Back Button: without saving, go back to the last page
Next Button: save, and if successful, go to the next page
Configure More: re-skinned next button
Exit Setup: without saving, go to the home page ("finish")
Jump In: on the "ready" page, it exits the setup ("finish"), on the
last page, it saves, and if successful, go to the home page

This commit re-aligns the code to reflect this reality without the
unnecessary indirections leftover from previous refactors.
Move field components into subfolders, remove dependency on string
resolution with the runtime resolver.
* Move Wizard back into main app, remove Wizard addon
* Remove Wizard-related resolver or build hacks
* Add a `app/static` folder in the app, add it to `staticAppPaths`
* Install and enable `@embroider/router`
* Add "wizard" to `splitAtRoutes`

In a fully optimized Embroider app, route-based code splitting more
or less Just Work™ – install `@embroider/router`, subclass from it,
configure which routes you want to split and that's about it.

However, our app is not "fully optimized", by which I mean we are
not able to turn on all the `static*` flags.

In Embroider, "static" means "statically analyzable". Specifically
it means that all inter-dependencies between modules (files) are
explicitly expressed as `import`s, as opposed to `{{i18n ...}}`
magically means "look for the default export in app/helpers/i18n.js"
or something even more dynamic with the resolver.

Without turning on those flags, Embroider behaves conservatively,
slurps up all `app` files eagerly into the primary bundle/chunks.
So, while you _could_ turn on route-based code splitting, there
won't be much to split.

The commits leading up to this involves a bunch of refactors and
cleanups that 1) works perfectly fine in the classic build, 2) are
good and useful in their own right, but also 3) re-arranged thigns
such that most dependencies are now explicit.

With those in place, I was able to move all the wizard code into
the "app/static" folder. Embrodier does not eagerly pull things from
this folder into any bundle, unless something explicitly "asks" for
them via `imports`. Conversely, thigns from this folder are not
registered with the resolver and are not added to the `loader.js`
registry.

In conjunction with route-based code splitting, we now have the
ability to split out islands of on-demand functionalities from the
main app bundle.

When you split a route in Embroider, it automatically creates a
bundle/entrypoint with the relevant routes/templates/controllers
matching that route prefix. Anything they import will be added to
the bundle as well, assuming they are not alreay in the main app
bundle, which is where the "app/static" folder comes into play.

The "app/static" folder name is not special. It is configured in
ember-cli-build.js. Alternatively, we could have left everything
in their normal locations, and add more fine-grained paths to the
`staticAppPaths` array. I just thought it would be easy to manage
and scale, and less error-prone to do it this way.

Note that putting things in `app/static` does not guarentee that
it would not be part of the main app bundle. For example, if we
were to add an `import ... from "app/static/wizard/...";` in a
main bundle file (say, `app.js`), then that chunk of the module
graph would be pulled in. (Consider using `await import(...)`?)

Overtime, we can build better tooling (e.g. lint rules and babel
macros to make things less repetitive) as we expand the use of
this pattern, but this is a start.
davidtaylorhq added a commit that referenced this pull request Dec 6, 2023
Extracted from #23678

Co-authored-by: Godfrey Chan <godfreykfc@gmail.com>
carsick pushed a commit that referenced this pull request Dec 6, 2023
Extracted from #23678

Co-authored-by: Godfrey Chan <godfreykfc@gmail.com>
davidtaylorhq added a commit that referenced this pull request Dec 7, 2023
This commit refactors the Wizard component code in preparation for moving it to the 'static' directory for Embroider route-splitting. It also includes a number of general improvements and simplifications.

Extracted from #23678

Co-authored-by: Godfrey Chan <godfreykfc@gmail.com>
davidtaylorhq added a commit that referenced this pull request Dec 20, 2023
(extracted from #23678)

* Move Wizard back into main app, remove Wizard addon
* Remove Wizard-related resolver or build hacks
* Install and enable `@embroider/router`
* Add "wizard" to `splitAtRoutes`

In a fully optimized Embroider app, route-based code splitting more
or less Just Work™ – install `@embroider/router`, subclass from it,
configure which routes you want to split and that's about it.

However, our app is not "fully optimized", by which I mean we are
not able to turn on all the `static*` flags.

In Embroider, "static" means "statically analyzable". Specifically
it means that all inter-dependencies between modules (files) are
explicitly expressed as `import`s, as opposed to `{{i18n ...}}`
magically means "look for the default export in app/helpers/i18n.js"
or something even more dynamic with the resolver.

Without turning on those flags, Embroider behaves conservatively,
slurps up all `app` files eagerly into the primary bundle/chunks.
So, while you _could_ turn on route-based code splitting, there
won't be much to split.

The commits leading up to this involves a bunch of refactors and
cleanups that 1) works perfectly fine in the classic build, 2) are
good and useful in their own right, but also 3) re-arranged things
such that most dependencies are now explicit.

With those in place, I was able to move all the wizard code into
the "app/static" folder. Embroider does not eagerly pull things from
this folder into any bundle, unless something explicitly "asks" for
them via `imports`. Conversely, things from this folder are not
registered with the resolver and are not added to the `loader.js`
registry.

In conjunction with route-based code splitting, we now have the
ability to split out islands of on-demand functionalities from the
main app bundle.

When you split a route in Embroider, it automatically creates a
bundle/entrypoint with the relevant routes/templates/controllers
matching that route prefix. Anything they import will be added to
the bundle as well, assuming they are not already in the main app
bundle, which is where the "app/static" folder comes into play.

The "app/static" folder name is not special. It is configured in
ember-cli-build.js. Alternatively, we could have left everything
in their normal locations, and add more fine-grained paths to the
`staticAppPaths` array. I just thought it would be easy to manage
and scale, and less error-prone to do it this way.

Note that putting things in `app/static` does not guarantee that
it would not be part of the main app bundle. For example, if we
were to add an `import ... from "app/static/wizard/...";` in a
main bundle file (say, `app.js`), then that chunk of the module
graph would be pulled in. (Consider using `await import(...)`?)

Overtime, we can build better tooling (e.g. lint rules and babel
macros to make things less repetitive) as we expand the use of
this pattern, but this is a start.

Co-authored-by: Godfrey Chan <godfreykfc@gmail.com>
@davidtaylorhq
Copy link
Member

Final part merged via cbc28e8

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
js-modernization Tasks related to our JS modernization project, including the Ember 4.0+ upgrade
3 participants