-
Notifications
You must be signed in to change notification settings - Fork 546
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
New Async Component API #148
Conversation
After playing a bit with it (now that it is released in As @yyx990803 pointed out import { defineAsyncComponent, defineComponent } from 'vue';
export default defineComponent({
name: 'App',
components: {
PendingRaces: defineAsyncComponent(() => import('./PendingRaces.vue'))
},
}); |
I find it rather strange that we have some API's that include action in its name and some don't. |
I would also like to know how we can restart async component loader in case it fails? Probably something like a What about delegating it to Suspense? <template>
<Suspense @error="onSuspenseError" ref="suspense">
<MyAsyncComponent />
</Suspense>
</template>
<script>
export default {
methods: {
onSuspenseError(asyncComponent, error) {
this.$refs.suspense.retry(asyncComponent);
// or
asyncComponent.retry();
}
}
}
</script> How do we pass promise rejection result to an error component? |
How refs should be used with async components in options and composition API? |
@cexbrayat yeah, on a second thought, it does make sense to make them consistent. Would you mind re-doing the PR? |
@yyx990803 Sure, here it is vuejs/core#888 Thank you for considering it. |
@CyberAP reactivity APIs are used in much higher frequency so we are sacrificing a bit of explicitness for readability. Component declarations do not have this problem. Re retry - what about an const Foo = defineAsyncComponent({
loader: () => import('./Foo.vue'),
onError: (error, retry) => {
// decide whether to retry based on error...
// if retry() is called, it will not go into error state (stay on loading state)
},
error: ({ error, retry }) => {
// the error component also receives the retry function via props so you can
// let the user decide whether to retry
return h('button', { onClick: retry }, 'retry')
}
}) As for retry with Suspense - I think it can be considered a feature addition that isn't going to cause conflicts with the current design. In general, the whole Suspense API could use more polishing (it will be labeled as experimental for initial 3.0 release), for example how to replicate the delay / error handling behavior of 2.x async components with Suspense. I think we should make it a separate RFC. |
To me that looks a bit confusing since it's hard to tell what triggers what. Is it What if the loader could receive these callbacks? const Foo = defineAsyncComponent({
loader: (error, retry) => {
return import('./Foo.vue')
.catch(res => {
if (res.code) return retry();
error();
});
},
}) If the return result in
This to me is also a bit confusing, especially that an option and an argument (even destructured) share the same name. For beginners it might not be obvious which closure is used for |
@CyberAP passing const Foo = defineAsyncComponent({
loader: (retry) => {
return import('./Foo.vue')
.catch(error => {
if (error.code) {
return retry()
} else {
throw error
}
})
},
}) Re |
Does it suppose that only one retry is allowed? Or could it be configurable somehow? If configurable, I'd prefer it if that was an option to function loadComponent (path, maxRetries = 1) {
let retriesCount = 0
return (retry) => {
retriesCount++
return import(path).catch((error) => {
if (error.code && retriesCount <= maxRetries) {
retry()
} else {
throw error
}
})
}
} |
@leopiccionia doable, but the downside is when the max retries have been reached, Vue wouldn't be able to report the original error unless you pass it to the const Foo = defineAsyncComponent({
loader: retry => import('./Foo.vue').catch(err => {
if (err.code) return retry(err)
throw err
}),
maxRetries: 3
}) You will need to remember to
It's still quite a bit of boilerplate and potential pitfalls. On the other hand, instead of manually catching and retrying, the only part you really want to write is deciding whether to retry based on the caught error, so maybe this is easier to use: const Foo = defineAsyncComponent({
loader: () => import('./Foo.vue'),
retryWhen: error => error.code === 123,
maxRetries: 3
}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This RFC is now in final comments stage. An RFC in final comments stage means that: The core team has reviewed the feedback and reached consensus about the general direction of the RFC and believe that this RFC is a worthwhile addition to the framework. |
I agree that I really like the simplicity of the Would providing a default import { retryStrategy } from "vue"
const Foo = defineAsyncComponent({
loader: () => import('./Foo.vue'),
retryStrategy: retryStrategy({
retryWhen: error => error.code === 123,
maxRetries: 3
})
}) |
In a case where we'd like to handle errors inside const Foo = defineAsyncComponent({
loader: () => import('./Foo.vue').catch(error => {
handleError(error);
throw error;
}),
retryWhen: error => error.code === 123,
maxRetries: 3
}) With a callback argument that could look like this: const Foo = defineAsyncComponent({
loader: (fail, retry, tries) => import('./Foo.vue').catch(error => {
handleError(error);
if (tries <= 3 && error.code === 123) { retry(); }
else { fail(); }
}),
}) |
Errors thrown in the loader can be handled by standard Vue error handling mechanisms ( |
imho the onError is the simplest solution, which informs vue & allows functional style: const Foo = defineAsyncComponent({
loader: () => import('./Foo.vue'),
onError: ({ error, fail, retry, attempts }) => {},
});
const ON_ERROR_DEFAULT = ({ error, fail }) => {
console.log(error);
fail(error);
};
function retry(maxAttempts) {
return ({ error, fail, retry, attempts }) => {
if (attempts < maxAttempts) {
return void retry();
}
console.log(error);
fail(error);
}
}
const Foo = defineAsyncComponent({
loader: () => import('./Foo.vue'),
onError: retry(3),
}); edit: function retryWhen(condition) {
return ({ error, fail, retry, attempts }) => {
if (condition({ error, attempts })) {
return void retry();
}
console.log(error);
fail(error);
}
}
const Foo = defineAsyncComponent({
loader: () => import('./Foo.vue'),
onError: retryWhen(({ error, attempts }) => Math.random() > 0.5),
}); |
That makes sense and I get the idea of having as little verbosity and complexity as possible, but at the same time throwing errors within a catch block in order to launch retry is still not optimal in my opinion, primarily because it's implicit in its intent. What about moving catch block to a constructor option? And accept a boolean return from that callback to decide whether to retry or not? That way error is still proxied by Vue and can be handled by the user. const Foo = defineAsyncComponent({
loader: () => import('./Foo.vue'),
catch: ({ error, tries }) => {
handleError(error);
return (error.code !== 500 || tries <= 3);
}
}) Or in a case we don't want to handle an error: const Foo = defineAsyncComponent({
loader: () => import('./Foo.vue'),
catch: ({ tries }) => tries <= 3,
}) |
@CyberAP that doesn't seem to be much different from I agree with @backbone87 that a separate |
BREAKING CHANGE: `retryWhen` and `maxRetries` options for `defineAsyncComponent` has been replaced by the more flexible `onError` option, per vuejs/rfcs#148
``` js | ||
const Foo = defineAsyncComponent({ | ||
// ... | ||
onError(error, retry, fail, attempts) { | ||
if (error.message.match(/fetch/) && attempts <= 3) { | ||
// retry on fetch errors, 3 max attempts | ||
retry() | ||
} else { | ||
fail() | ||
} | ||
} | ||
}) | ||
``` |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@CyberAP that doesn't seem to be much different from
retryWhen
, and it doesn't have the flexibility @Morgul wanted.I agree with @backbond87 that a separate
onError
option might be the most straightforward.
I am ok with that idea as well. Could onError
arguments be contained in a single destructurable object? So there's no need to remember the exact order in which they're passed to a callback.
``` js | |
const Foo = defineAsyncComponent({ | |
// ... | |
onError(error, retry, fail, attempts) { | |
if (error.message.match(/fetch/) && attempts <= 3) { | |
// retry on fetch errors, 3 max attempts | |
retry() | |
} else { | |
fail() | |
} | |
} | |
}) | |
``` | |
``` js | |
const Foo = defineAsyncComponent({ | |
// ... | |
onError({ error, retry, fail, attempts }) { | |
if (error.message.match(/fetch/) && attempts <= 3) { | |
// retry on fetch errors, 3 max attempts | |
retry() | |
} else { | |
fail() | |
} | |
} | |
}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't know, the order feels pretty natural to me. With destructure instead of the order, you'll have to remember the exact names. It's not much different imo.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Its less about remembering imho, but more about needing only a few of the args most of the time, depending on the use case. See my examples
I do really like |
@Morgul |
i would like to make another proposal: type Loader = () => Promise<Component | EsModuleComponent>;
// simple variant
// - loads once
// - uses browser timeout for requests
// - uses errorComponent on rejection
defineAsyncComponent({
loader: () => import("./Foo.vue"),
errorComponent: ErrorComponent,
loadingComponent: LoadingComponent,
delay: 200
})
// using timeout helper
function timeout(t: number, loader: Loader): Loader {
return () => Promise.all(rejectAfter(t), loader());
}
defineAsyncComponent({
loader: timeout(3000, () => import("./Foo.vue")),
errorComponent: ErrorComponent,
loadingComponent: LoadingComponent,
delay: 200
})
// using retryWhen helper
type Condition = (e: unknown, attempt: number) => boolean;
function retryWhen(condition, loader: Loader): Loader {
return async () => {
let attempt = 0;
while(++attempt) {
try {
return await loader();
} catch (e) {
console.log(e);
if (!condition(e, attempt)) {
throw e;
}
}
}
}
}
defineAsyncComponent({
loader: retryWhen((e, attempt) => Math.random() * attempt > 0.5, () => import("./Foo.vue")),
loadingComponent: LoadingComponent,
errorComponent: ErrorComponent,
delay: 200
})
// using retry helper
function retry(attempts, loader: Loader): Loader {
return () => retryWhen((e, attempt) => attempt <= attempts, loader);
}
defineAsyncComponent({
loader: retry(3, () => import("./Foo.vue")),
loadingComponent: LoadingComponent,
errorComponent: ErrorComponent,
delay: 200
})
// using the fallback helper
function fallback(...loaders: Loader[]): Loader {
return async () => {
let lastError = new Error();
for (const loader of loaders) {
try {
return await loader();
} catch (e) {
console.log(e);
lastError = e;
}
}
throw lastError;
}
}
defineAsyncComponent({
loader: fallback(() => import("./Foo.vue"), () => import("./Bar.vue"), () => import("./Baz.vue")),
loadingComponent: LoadingComponent,
errorComponent: ErrorComponent,
delay: 200
})
// combining helpers
defineAsyncComponent({
loader: timeout(10000, fallback(timeout(1000, () => import("./Foo.vue")), retry(3, () => import("./Bar.vue")), () => import("./Baz.vue"))),
loadingComponent: LoadingComponent,
errorComponent: ErrorComponent,
delay: 200
})
// custom defineAsyncComponent
function myDefineAsyncComponent(loader: Loader, t = 10000, attempts = 3): AsyncComponent {
return defineAsyncComponent({
loader: retry(attempts, timeout(t, loader)),
loadingComponent: LoadingComponent,
errorComponent: ErrorComponent,
delay: 200
})
}
myDefineAsyncComponent(() => import("./Foo.vue"));
// even errorComponent & loadingComponent can be defined in terms of a custom loader
function errorComponent(loader: Loader, comp: Component): Loader {
return loader().catch(() => comp);
}
function makeLoadingProxy(loading: Promise<Component>, comp: Component): () => FunctionalComponent {
return () => {
const target = ref(comp);
loading.then((c) => void (target.value = c));
return (props, { slots }) => h(target.value, props, slots);
}
}
function loadingComponent(loader: Loader, comp: Component, delay: number): Loader {
return () => {
const loading = loader();
return Promise.race(
loading,
resolveAfter(delay).then(makeLoadingProxy(loading, comp))
);
};
} |
@backbone87 I think this is a bit too convoluted for typical usage, and most of it can be done in userland. If you prefer such a style you can surely do that, but I don't think it would be a good fit for average users. |
@yyx990803 yes! and that occured to me only after i wrote this. the conclusion i would draw from this:
edit: function defineLoadedComponent(loader: Loader): FunctionalComponent {
// maybe memoize?
// loader = memoize(loader);
return async (props, { slots }) => h(await loader(), props, slots);
} |
@backbone87 internally |
I think most of the design issues are settled in this RFC. I will merge this tomorrow if no major objections are raised before then. |
Full Rendered Proposal