@vueschool_io
vueschool.io
New Features and Migrating
Lead Instructor @ Vue School
Full Stack developer (10 years)
Husband and Father
* backported to Vue 2.7
Pinia Experiment Begins
Nov, 2019
RFC Repo Created
Jan 14, 2019
Vue 3 Codebase Goes Public
Jan 3, 2020
Vue 3 Soft Launch
(One Piece)
Sep 18, 2020
Vue 3 New Default
Jan 20, 2022
Evan Announced Dev on Vite.js
Jan 12, 2020
๐ฃ
Vue 3 Development Announced
Feb 2018
Volar
<script setup>
more!
Benefits and why upgrade
A one-second site speed improvement can increase mobile conversions by up to 27%.
* backported to Vue 2.7
โ
Stakeholders
ย ย ย ย Faster turn around times, less back and forth
โ
Site Visitors/Customers
ย ย ย ย Less bugs, more features faster
โ Developers
ย ย ย ย Better DX, Iterate faster, spend less time debugging
// MyInput.vue
<template>
<input
@input="$emit('input', $event.target.value)"
:value="value"
/>
</template>
<script>
export default {
props:{
value: String
}
}
</script>
Support v-model on a component by emitting input and accepting a value prop
v2
<MyInput v-model="name" />
// MyInput.vue
<template>
<input
@input="$emit('update:modelValue', $event.target.value)"
:value="modelValue"
/>
</template>
<script>
export default {
props:{
modelValue: String
}
}
</script>
Support v-model on a component by emitting update:modelValue and accepting a modelValue prop
v3
<MyInput v-model="name" />
// MyInput.vue
<template>
<input
@input="$emit('update:modelValue', $event.target.value)"
:value="modelValue"
/>
<input
@input="$emit('update:email', $event.target.value)"
:value="title"
/>
<input
@input="$emit('update:password', $event.target.value)"
:value="title"
/>
</template>
<script>
export default {
props:{
modelValue: String,
email: String,
password: String,
}
}
</script>
Provide argument to v-model to specify any prop/update:[prop]
v3
<MyInput
v-model="name"
v-model:email="email"
v-model:password="password"
/>
<MyComponent
:title="pageTitle"
@update:title="pageTitle = $event"
/>
<!-- Shortand for Above -->
<MyComponent :title.sync="pageTitle" />
Multiple v-models replace .sync
v2
<MyComponent
:title="pageTitle"
@update:title="pageTitle = $event"
/>
<!-- Shortand for Above -->
<MyComponent v-model:title="pageTitle" />
Multiple v-models replace .sync
v3
<MyComponent v-model.capitalize="myText" />
Vue 3 also allows us to create custom modifiers for v-model
<!-- MyComponent.vue-->
<script>
export default {
props: {
//...
modelModifiers: {
default: () => ({})
}
},
created() {
console.log(this.modelModifiers) // { capitalize: true }
}
//...
}
</script>
Check for them on the modelModifiers prop. Added modifiers will be a boolean true
<MyComponent v-model.capitalize="myText" />
<!-- MyComponent.vue-->
<script>
export default {
//...
methods: {
emitValue(e) {
let value = e.target.value
if (this.modelModifiers.capitalize) {
value = value.charAt(0).toUpperCase() + value.slice(1)
}
this.$emit('update:modelValue', value)
}
}
}
</script>
Then you can alter the emitted value based on the modifier
Vue.set(this.frameworks, index, 'Vue')
v2
this.frameworks[index] = "Vue"
v3
Setting a new array item
Vue.set(this.framework, 'name', 'Vue')
v2
this.framework.name = "Vue"
v3
Adding a new object property
Vue.delete(this.framework, 'caveats')
v2
delete this.framework.caveats
v3
Deleting an object property
Less Caveats === More Intuitive === Less Bugs
aka. Multiple Root Elements
v2
<template>
<div>...</div>
<div>...</div>
</template>
โ
If you did this...
aka. Multiple Root Elements
v2
<template>
<div>...</div>
<div>...</div>
</template>
You'd get an error like this!
aka. Multiple Root Elements
v2
<template>
<div>
<div>...</div>
<div>...</div>
</div>
</template>
And have to wrap everything with a div
(which sometimes causes styling issues)
aka. Multiple Root Elements
v3
<template>
<div>...</div>
<div>...</div>
</template>
It's no problem! ๐
aka. Multiple Root Elements
v3
<template>
<div>...</div>
<div v-bind="$attrs">...</div>
</template>
And you can specify where to put the fall-through attributes
v2
<template>
<label>
<input type="text" v-bind="$attrs" v-on="$listeners" />
</label>
</template>
<script>
export default {
inheritAttrs: false
}
</script>
Listeners separate from $attrs and if you wanted them to fall through you must remember to bind seperately
v3
//MyInput.vue
<template>
<label>
<input type="text" v-bind="$attrs" />
</label>
</template>
<script>
export default {
inheritAttrs: false
}
</script>
Listeners combined with $attrs. Defined as functions prefixed with on
<MyInput id='my-input' @close="..."/>
$attrs === {
id: 'my-input',
onClose(){
...
}
}
v2
// MyInput.vue
<template>
<label>
<input type="text" v-bind="$attrs" />
</label>
</template>
<script>
export default {
inheritAttrs: false
}
</script>
Class and Style separate from $attrs
Given this component definition...
v2
<MyInput id="my-id" class="my-class" />
Class and Style separate from $attrs
And used like this...
<label class="my-class">
<input type="text" id="my-id" />
</label>
Would render this HTML
v3
<MyInput id="my-id" class="my-class" />
Class and Style included with $attrs
And used like this...
<label>
<input type="text" id="my-id" class="my-class" />
</label>
Would render this HTML
โฐ 20 mins
<template functional>
<component
:is="`h${props.level}`"
v-bind="attrs"
v-on="listeners"
>
<slot></slot>
</component>
</template>
<script>
export default {
props: ["level"],
};
</script>
v2
In v2, functional components provided a more performant alternative when component state was not needed
No $ prefix
<template>
<component
:is="`h${props.level}`"
v-bind="$attrs"
>
<slot></slot>
</component>
</template>
<script>
export default {
props: ["level"],
};
</script>
v3
In v3, the performance difference for stateful components is negligible so functional SFC's are removed
$ prefix
No functional
keyword
<script>
// note that h is imported from vue
// instead of provided as an argument to the render function
// (another diff between v2 and v3)
import { h } from "vue";
// functional components is a render function
const DynamicHeading = (props, context) => {
return h(`h${props.level}`, context.attrs, context.slots);
};
DynamicHeading.props = ["level"];
export default DynamicHeading;
</script>
v3
Or you can define functional components as plain functions
ย
<template>
<h1>Bank Account Balance</h1>
<p>{{ accountBalance | currencyUSD }}</p>
</template>
<script>
export default {
//...
filters: {
currencyUSD(value) {
return '$' + value
}
}
}
</script>
v2
In v2, you could use filters to format data in the template
ย
Custom syntax that involves:
<template>
<h1>Bank Account Balance</h1>
<p>{{ accountInUSD }}</p>
</template>
<script>
export default {
//...
computed: {
accountInUSD() {
return '$' + this.accountBalance
}
}
}
</script>
v3
In v3, filters are removed. Just use a computed prop instead.
ย
const app = createApp(App)
app.config.globalProperties.$filters = {
currencyUSD(value) {
return '$' + value
}
}
v3
In v3, global filters can be replaced with globally defined methods
ย
<template>
<h1>Bank Account Balance</h1>
<p>{{ $filters.currencyUSD(accountBalance) }}</p>
</template>
v2
In Vue 2 you could use keycodes as modifiers for keyboard events
ย
<!-- keycode 75 === 'k' -->
<input v-on:keyup.75="doThing" />
v3
In Vue 3 you use the event's key value instead
<input v-on:keyup.k="doThing" />
<input v-on:keyup.k="doThing" />
v3
For multi-word key names you'll use the kebab case
<input v-on:keyup.arrow-down="doThing" />
v3
The keys for some punctuation marks can just be included literally.
(This excludes "
, '
, /
, =
, >
, and .
You can check these on the event in your handler)
<input v-on:keyup.,="commaPress" />
KeyboardEvent.keyCode is now deprecated in browsers and no longer recommended for use. Therefore Vue 3 removes them.
ย
<input v-on:keyup.k="doThing" />
v2
<template>
<div>
<my-button>Change logo</my-button>
</div>
</template>
<script>
import MyButton from './MyButton'
export default {
components: { MyButton },
mounted() {
console.log(this.$children[0]) // VueComponent (MyButton)
}
}
</script>
In Vue 2, you could access all a components child components via $children
ย
v3
In Vue 3, $children no longer exists. If you need access to a component via the parent you can use a template ref instead
ย
<template>
<div>
<my-button ref="button">Change logo</my-button>
</div>
</template>
<script>
import MyButton from './MyButton'
export default {
components: { MyButton },
mounted() {
console.log(this.$refs.button) // VueComponent (MyButton)
}
}
</script>
โฐ 15 mins
Skip
โฐ 10 mins
* backported to Vue 2.7
Code Organization
Logic Reuse
Improved
TypeScript Support
Full Workshop Dedicated to CAPI
โก๏ธ Super Quick Overview
new Vue({
data(){
return {
loading: false,
count: 0,
user: {}
}
},
computed: {
double () { return this.count * 2 },
fullname () {/* ... */}
},
methods: {
increment () { this.count++ },
fetchUser () {/* ... */}
}
})
import { ref, computed, watch } from 'vue'
const loading = ref(false)
const count = ref(0)
const user = reactive({})
new Vue({
data(){
return {
loading: false,
count: 0,
user: {}
}
},
computed: {
double () { return this.count * 2 },
fullname () {/* ... */}
},
methods: {
increment () { this.count++ },
fetchUser () {/* ... */}
}
})
Define reactive data with
ref
or
reactive
const double = computed(()=> count.value * 2)
const fullname = computed(()=>({ /* ... */ }))
new Vue({
data(){
return {
loading: false,
count: 0,
user: {}
}
},
computed: {
double () { return this.count * 2 },
fullname () {/* ... */}
},
methods: {
increment () { this.count++ },
fetchUser () {/* ... */}
}
})
Define derived data with
computed
const increment = ()=> count.value++
function fetchUser(){ /* ... */ }
new Vue({
data(){
return {
loading: false,
count: 0,
user: {}
}
},
computed: {
double () { return this.count * 2 },
fullname () {/* ... */}
},
methods: {
increment () { this.count++ },
fetchUser () {/* ... */}
}
})
Define methods as
functions
Full Workshop Dedicated to CAPI
Modal overlay without teleport usually relies on fixed positioning
<template>
<button @click="open = true">Open Modal</button>
<div v-if="open" class="modal">
<slot></slot>
<footer>
<button @click="open = false">Close</button>
</footer>
</div>
</template>
<script>
// ....
</script>
<style scoped>
.modal {
@apply fixed;
}
</style>
v2
Append markup to any arbitrary element no matter where the component lives
<template>
<button @click="open = true">Open Modal</button>
<Teleport to="body">
<div v-if="open" class="modal">
<slot></slot>
<footer>
<button @click="open = false">Close</button>
</footer>
</div>
</Teleport>
</template>
<script>
// ....
</script>
<style scoped>
.modal {
@apply absolute;
}
</style>
v3
`to` prop takes a CSS selector or an actual DOM node
<template>
<!-- string query selector-->
<Teleport to="body">
<!--or actual element-->
<Teleport :to="body">
</template>
<script>
export default {
data() {
return {
body: document.querySelector("body"),
};
},
};
</script>
can combine <Teleport> with <Transition>
<template>
<Teleport :to="body">
<Transition>
<div v-if="open" class="modal">
//...
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.v-enter-active,
.v-leave-active {
transition: opacity 0.5s ease;
}
.v-enter-from,
.v-leave-to {
opacity: 0;
}
</style>
Can have multiple teleports active at one time.
<Teleport to="#modals">
<div>A</div>
</Teleport>
<Teleport to="#modals">
<div>B</div>
</Teleport>
<div id="modals">
<div>A</div>
<div>B</div>
</div>
Can dynamically disable. Useful to conditionally display inside component
<Teleport :disabled="isMobile">
...
</Teleport>
ย The teleport `to` target must already exist in the DOM when the component using <Teleport> is mounted.
โฐ 25 mins
โฐ 25 mins
โฐ 25 mins
and more!
In v2, inline styles were the only option for dynamic CSS rules
<template>
<div
class="swatch"
:style="{ backgroundColor: color }"
></div>
</template>
<script>
export default{
data(){
return {
color: "red"
}
}
}
</script>
v2
Re-use would mean re-declaring the inline style
<template>
<div
class="swatch"
:style="{ backgroundColor: color }"
></div>
<div
class="swatch"
:style="{ backgroundColor: color }"
></div>
</template>
<script>
export default{
data(){
return {
color: "red"
}
}
}
</script>
v2
In v3, you can bind CSS values directly in the style block
v3
<template>
<div>
<div class="swatch"></div>
<div class="swatch"></div>
</div>
</template>
<script>
export default{
data(){
return { color: "red" }
}
}
</script>
<style scoped>
.swatch{
background-color: v-bind(color);
}
</style>
Defines a hashed CSS custom property on the root component element
(or on each element if no root)
v3
<template>
<div>
<div class="swatch"></div>
<div class="swatch"></div>
</div>
</template>
<script>
export default{
data(){
return { color: "red" }
}
}
</script>
<style scoped>
.swatch{
background-color: v-bind(color);
}
</style>
Can use dot notation to target nested data (must use quotes)
v3
<template>
<div class="swatch"></div>
<div class="swatch"></div>
</template>
<script>
export default{
data(){
return {
colors:{ primary: "red" }
}
}
}
</script>
<style scoped>
.swatch{
background-color: v-bind('color.primary');
}
</style>
v-bind must the entire value
v3
<template>
<div class="swatch"></div>
<div class="swatch"></div>
</template>
<script>
export default{
data(){
return {
colors:{ primary: "red" },
width: 100
}
},
computed:{
widthInPixels(){ return this.width + 'px'}
}
}
</script>
<style scoped>
.swatch{
background-color: v-bind('color.primary');
width: v-bind(width)px; /* โ This won't work */
width: v-bind(widthInPixels);
}
</style>
v3
<style scoped>
/* Style child components */
.a :deep(.b) {}
/* Style slot content */
:slotted(div) {}
/* Quickly define a global rule */
:global(.red) {}
</style>
<style>
/* Same as :global in scoped tag above */
.red{}
</style>
โฐ 15 mins
Declare Component Events
Emits scattered throughout component
<!-- DataSender.vue -->
<template>
<div>
<button @click="sendData">Send Data</button>
</div>
</template>
<script>
export default {
methods: {
sendData() {
this.$emit('sending-start')
// send data to API
this.$emit('sending-complete')
}
}
}
</script>
v2
Declare all events with emits option
<!-- DataSender.vue -->
<template>
<div>
<button @click="sendData">Send Data</button>
</div>
</template>
<script>
export default {
emits:['sending-start', 'sending-complete'],
methods: {
sendData() {
this.$emit('sending-start')
// send data to API
this.$emit('sending-complete')
}
}
}
</script>
v3
Benefits of Emit Option
<script>
export default {
emits:['sending-start', 'sending-complete'],
//...
}
</script>
Important for devs and tooling
Benefits of Emit Option
Benefits of Emit Option
<MyButton @click.native="doThing" />
Benefits of Emit Option
Validate event payload
<!-- DataSender.vue -->
<!--...-->
<script>
export default {
// define as object to use validation
emits:{
// null is no validation
'sending-start': null,
// or provide function to validate
// return truthy for valid, falsy for invalid
'sending-complete'(payload){
return !!(typeof payload.duration === 'number' && payload.response);
}
},
methods: {
sendData() {
this.$emit('sending-start')
// send data to API
this.$emit('sending-complete', {
duration: 2,
// response: 'how are you'
})
}
}
}
</script>
v3
Validate event payload with TypeScript
<script lang="ts">
import { defineComponent } from 'vue'
export default defineComponent({
emits: {
addBook(payload: { bookName: string }) {
// perform runtime validation
return payload.bookName.length > 0
}
},
methods: {
onSubmit() {
this.$emit('addBook', {
bookName: 123 // Type error!
})
this.$emit('non-declared-event') // Type error!
}
}
})
</script>
v3
โฐ 20 mins
Skip
Admittedly a little difficult to understand if you've never heard of it before
Orchestrate async dependencies
Without Suspense
With Suspense
With Suspense
ย
(with more control at component level)
AKA - eliminate popcorn loading
In Vue 2 you'd have local loading data in
the component
<!-- PostsList.vue -->
<script>
export default{
data(){
return {
loading: true,
posts: null
}
},
async created(){
const res = await fetch("https://myapi.com/posts")
this.posts = await res.json()
this.loading = false
}
}
</script>
<template>
<div>
<AppSpinner v-if="loading"/>
<div v-else>...</div>
</div>
</template>
v2
Somewhere higher in the higharchy would use the component and others like it
<!-- ParentComponent.vue -->
<template>
<div>
<PostsList />
<UsersList />
<CommentsList />
</div>
</template>
v2
Let's take the same component and
modify it for Vue 3
<!-- PostsList.vue -->
<script>
export default{
data(){
return {
loading: true,
posts: null
}
},
async created(){
const res = await fetch("https://myapi.com/posts")
this.posts = await res.json()
this.loading = false
}
}
</script>
<template>
<div>
<AppSpinner v-if="loading"/>
<div v-else>...</div>
</div>
</template>
v2
In Vue 3 you can get rid of all that loading state in the component
<!-- PostsList.vue -->
<script>
export default{
data(){
return {
posts: null
}
},
async created(){
const res = await fetch("https://myapi.com/posts")
this.posts = await res.json()
}
}
</script>
<template>
<div>
<div>...</div>
</div>
</template>
v3
Then return an async setup function
instead
<!-- PostsList.vue -->
<script>
export default{
async setup(){
const res = await fetch("https://myapi.com/posts")
const posts = await res.json()
return { posts }
},
}
</script>
<template>
<div>
<div>...</div>
</div>
</template>
v3
Don't worry too much about how setup works right now
With script setup you can use a top level await
<!-- PostsList.vue -->
<script setup>
const res = await fetch("https://myapi.com/posts")
const posts = await res.json()
</script>
<template>
<div>
<div>...</div>
</div>
</template>
v3
Don't worry too much about how setup works right now
Then in the parent you use suspense to show a
single loader once all promises have resolved
<!-- ParentComponent.vue -->
<template>
<div>
<Suspense>
<!-- Put all the async components
inside the default slot -->
<template #default>
<PostsList />
<UsersList />
<CommentsList />
</template>
<!-- And your loader
in the fallback slot -->
<template #fallback>
<AppSpinner/>
</template>
</Suspense>
</div>
</template>
v3
Also works with asyncComponents
(different chunk from main js bundle)
<script>
import { defineAsyncComponent } from 'vue'
export default {
components: {
AdminPage: defineAsyncComponent(() =>
import('./components/AdminPageComponent.vue')
)
}
}
</script>
<template>
<Suspense>
<template #default> <AdminPage /> </template>
<template #fallback> <AppSpinner/> </template>
</Suspense>
</template>
v3
Total control by mixing async setup and local loading state
<!-- ParentComponent.vue -->
<template>
<div>
<Suspense>
<template #default>
<!-- has async setup -->
<PostsList />
<!-- has async setup -->
<UsersList />
<!-- handles own loading state -->
<CommentsList />
</template>
<template #fallback>
<AppSpinner/>
</template>
</Suspense>
</div>
</template>
v3
Does it do error handling?
Suspense is technically an experimental feature
But Nuxt 3 uses in production... so I wouldn't
worry about Vue.js core making breaking changes
โฐ 20 mins
โฐ 5 mins
The Official Migration guide provides:
Example of Filter
Besides the breaking changes already seen throughout the workshop, there are a few more worthy of noting
Async compnent syntax changed
<!-- Vue 2 -->
<script>
export default{
components:{
MyAsyncComponent: () => import('./MyAsyncComponent.vue')
}
}
</script>
<!-- Vue 3 -->
<script>
import {defineAsyncComponent} from "vue"
export default{
components:{
MyAsyncComponent: defineAsyncComponent(
() => import('./MyAsyncComponent.vue')
)
}
}
</script>
Watch on Arrays
<script>
export default{
data(){
return {
food: ['Hamburger', 'Hotdog', 'Spaghetti', 'Taco']
}
},
watch:{
// Vue 2 - will fire when array replaced, order changed, item added, etc
// Vue 3 - will only fire when array is replaced
food(){
console.log('food changed')
},
// Vue 3 - must provide for handler to fire on order changed, item added, etc
food:{
handler(){
console.log('food changed')
},
deep: true
}
}
}
</script>
Global API Treeshaking
<!-- Vue 2 -->
<script>
import Vue from 'vue'
Vue.nextTick(() => {
// something DOM-related
})
</script>
<!-- Vue 3 -->
<script>
import { nextTick } from 'vue'
nextTick(() => {
// something DOM-related
})
</script>
Global API Treeshaking
<!-- Vue 2 -->
<script>
import Vue from 'vue'
Vue.nextTick(() => {
// something DOM-related
})
</script>
<!-- Vue 3 -->
<script>
import { nextTick } from 'vue'
nextTick(() => {
// something DOM-related
})
</script>
Also Vue.version
<!-- Vue 2 -->
<script>
import Vue from 'vue'
console.log(Vue.version)
</script>
<!-- Vue 3 -->
<script>
import { version } from 'vue'
console.log(version)
</script>
Custom Directives Hooks
// Vue 2
Vue.directive('highlight', {
bind(el, binding, vnode) {
el.style.background = binding.value
}
})
// Vue 3
const app = Vue.createApp({})
app.directive('highlight', {
beforeMount(el, binding, vnode) {
el.style.background = binding.value
}
})
custom directive hooks mirrors component lifecycle hooks
Custom Directives Hooks
updated
, so this is redundant. Please use updated
instead.All breaking changes can be found at:
ย
The Official Migration guide provides:
๐ฅ
๐จ
๐จ
๐ฅ
Difficulty
๐ฉ Easy
๐จ Medium
๐ฅ Hard
๐ฉ
๐ฉ
ย
The Official Migration guide provides:
npm install vue@3.2.45
npm install @vue/compat
Step
1
Step
2
// vue.config.js (vue-cli)
module.exports = {
chainWebpack: config => {
config.resolve.alias.set('vue', '@vue/compat')
config.module
.rule('vue')
.use('vue-loader')
.tap(options => {
return {
...options,
compilerOptions: {
compatConfig: {
MODE: 2
}
}
}
})
}
}
Vue Docs also contains example configs for:
Vue CLI
Step
3
Step
3
Step
4
module.exports = {
//...
compilerOptions: {
compatConfig: {
MODE: 2,
MODE: 3
},
},
}
Means the Vue compiler should expect all compiler level code to be written as per Vue 3 specification
Step
4
module.exports = {
//...
compilerOptions: {
compatConfig: {
MODE: 2,
MODE: 3
},
},
}
Means the Vue compiler should expect all compiler level code to be written as per Vue 3 specification
More on compatConfig in a minute!
Step
5
Official dependencies will have their own migration guides. Others may or may not. (This is where things can get a little dicey, but the ecosystem has definitely progressed)
Step
6
IDs for warnings found here:
https://v3-migration.vuejs.org/migration-build.html#feature-reference
ID
Explanation
ย Link to docs
Step
6
// main.js
import { configureCompat } from 'vue'
// disable compat for certain features
configureCompat({
COMPONENT_V_MODEL: false,
FEATURE_ID_B: false
})
Step
6
export default {
compatConfig: {
COMPONENT_V_MODEL: false,
FEATURE_ID_B: false
}
// ...
}
Step
6
feature/vue-3-migration
and maybe a commit per feature ID?
Step
6
configureCompat({
COMPONENT_V_MODEL: false,
GLOBAL_MOUNT_CONTAINER: false,
GLOBAL_MOUNT: false,
GLOBAL_SET: false,
//...
})
Step
7
.v-enter
.v-enter-from
.v-leave
.v-leave-from
Step
7
configureCompat({
// ...
TRANSITION_CLASSES: false
})
Step
7
configureCompat({
MODE: 3
})
Step
8
// package.json
"@vue/compat": "^3.1.0",
// vue.config.js
module.exports = {
chainWebpack: (config) => {
config.resolve.alias.set("vue", "@vue/compat");
config.module
.rule("vue")
.use("vue-loader")
.tap((options) => {
return {
...options,
compilerOptions: {
compatConfig: {
MODE: 3,
},
},
};
});
},
};
import {
createApp,
configureCompat
} from "vue";
import App from "./App.vue";
configureCompat({
COMPONENT_V_MODEL: false,
GLOBAL_MOUNT_CONTAINER: false,
GLOBAL_MOUNT: false,
GLOBAL_SET: false,
//...
})
1
2
3
โฐ 30 mins
Do you accept this challenge? ๐ช
โฐ 15 minutes
See you tomorrow!