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

v3 proposal #2982

Open
wants to merge 70 commits into
base: main
Choose a base branch
from
Open

v3 proposal #2982

wants to merge 70 commits into from

Conversation

dead-claudia
Copy link
Member

@dead-claudia dead-claudia commented Oct 13, 2024

I want your feedback. Please use it, abuse it, and tell me what issues you find. Bugs, ergonomic issues, weird slowdowns, I want to know it all. Feel free to drop a comment here on whatever's on your mind, so I can easily track it.

Also, @MithrilJS/collaborators please don't merge this yet. I want feedback first. I also need to create a docs PR before this can get merged.

Description

This is my v3 proposal. It's pretty detailed and represents a comprehensive overhaul of the API. It synthesizes much of my API research. And yes, all tests pass. 🙂

Preserved the commit history if you're curious how I got here. (Reviewers: you can ignore that. Focus on the file diff - you'll save yourself a lot of time.)

If you want to play around, you can find artifacts here: https://github.com/dead-claudia/mithril.js/releases/tag/v3.0.0-alpha.0

This is not published to npm, so you'll have to use a GitHub tag/commit link. If you just want to mess around in a code playground, here's a link for you.

Quick line-by-line summary If you're short on time, here's a comprehensive list, grouped by type.

Highlights:

  • 9.12 KB to 6.28 KB, or a little over 30% smaller.
  • A base of ES2018 is assumed, including syntax.
  • IE compatibility is no more, and most IE hacks have been removed.
  • No more magic attributes to fuss with!

Additions:

  • New vnode: m.layout((dom) => ...), scheduled to be invoked after render
  • New vnode: m.remove((dom) => ...), scheduled to be invoked after the render in which it's removed from
  • New vnode: m.retain(), retains the current vnode at that given position
  • New vnode: m.set(contextKeys, ...children), sets context keys you can get via the third parameter in components
  • New vnode: m.use([...deps], ...children), works similar to React's useEffect
  • New vnode: m.init(async (signal) => ...), callback return works just like event listeners
  • New utility: tracked = m.tracked() to manage delayed removal
  • New utility: m.throttler()
  • New utility: m.debouncer()
  • New utility: m.withProgress(stream, onProgress), replaces old m.request's onprogress

Changes:

  • Vnode have reduced in size (10 to 6) and gained much shorter property names.
  • key: attribute -> m.key(key, ...)
  • m.buildPathname(template, query) -> m.p(template, query)
  • m.buildQueryString(query) -> m.q(query)
  • In event listeners, return false to prevent redraws.
  • Event listeners can return promises that are awaited before redrawing. Whatever it resolves to is taken as the return value.
  • If setting an attribute throws, the error is caught and logged unless removeOnThrow: false is passed to m.render.
  • If rendering a view throws, the view is removed and the error is caught and logged unless removeOnThrow: false is passed to m.render.
  • m.route(...) -> m(m.WithRouter, {prefix}, ...children)
  • m.mount(...) now returns a redraw function that works like m.redraw, but only for that one root. redraw is also set in the context via the redraw key.
  • m.render(...) now accepts an options object for its third parameter. redraw sets the redraw function (and is made available in the context).
  • Components are now either (attrs, old, context) => vnode functions or an (attrs, null, context) => ... function returning that

Removals:

  • All submodules other than mithril/stream - the main library no longer needs the DOM to load
  • The built-in router no longer does its own route dispatching
  • m.trust - use innerHTML instead
  • m.censor
  • Magic attributes
  • m.request - use fetch(m.p(template, query)) and (if necessary) m.withProgress(...) instead
  • m.vnode(...) - use m.normalize if it's truly necessary
  • m.mount(root, null) - use m.render(root, null) instead
  • m.redraw() -> either redraw function returned from m.mount or the redraw context key
  • oninit
  • oncreate/onupdate - use m.layout(...) instead
  • onremove - use m.remove(...) instead
  • onbeforeupdate - use m.retain() instead
  • onbeforeremove - use m.tracked() utility to manage parents instead
  • m.parsePathname
  • m.parseQueryString
  • ev.redraw = false - return/resolve with false or reject/throw from event handlers

Miscellaneous:

  • 30% smaller vnode structure, saving you some memory.
  • Optimized mithril/stream to require a lot less allocation - I don't have precise numbers, but it should both be faster and require less memory.
  • Redid the benchmark program.
Benchmarks

Details about my setup and related raw data follow the table.

Benchmark v2.2.8 (ops/sec) This PR (ops/sec) Change
do nothing 3203301 to 3203871 3226409 to 3226955 +0.72% to +0.72%
construct simpleTree 23677 to 23727 40955 to 40993 +72.8% to +73.0%
construct nestedTree 3072598 to 3072956 2939772 to 2940045 -4.33% to -4.32%
construct mutateStylesPropertiesTree 1500 to 1512 2186 to 2194 +45.1% to +45.7%
construct repeatedTree 3090285 to 3090710 2951911 to 2952260 -4.48% to -4.48%
construct shuffledKeyedTree 5654 to 5703 3152 to 3164 -44.5% to -44.3%
render simpleTree 3341 to 3411 5263 to 5333 +56.3% to +57.5%
render nestedTree 3734 to 3800 5114 to 5172 +36.1% to +37.0%
render mutateStylesPropertiesTree 37 to 42 33 to 36 -14.3% to -10.8%
render repeatedTree 32872 to 32975 28975 to 29098 -11.9% to -11.8%
render shuffledKeyedTree 300 to 343 358 to 380 +10.8% to +19.3%
add/remove simpleTree 798 to 815 649 to 664 -18.7% to -18.5%
add/remove nestedTree 855 to 871 596 to 609 -30.3% to -30.1%
add/remove mutateStylesPropertiesTree 42 to 44 35 to 36 -18.2% to -16.7%
add/remove repeatedTree 2709 to 2772 2519 to 2574 -7.14% to -7.01%
add/remove shuffledKeyedTree 407 to 414 310 to 316 -23.8% to -23.7%
mount simpleTree 667 to 688 515 to 534 -22.8% to -22.4%
mount all five 24 to 28 22 to 25 -10.7% to -8.33%
redraw simpleTree 3719 to 3781 5280 to 5344 +41.3% to +42.0%
redraw all five 49 to 54 31 to 41 -36.7% to -24.1%

Browser version:

  • Brave: 1.70.119 Chromium: 129.0.6668.70 (Official Build) (64-bit)
  • Revision: a15c836a4df987f118ece1645f54b081019049de
  • OS: Windows 10 Version 22H2 (Build 19045.4894)
  • JavaScript: V8 12.9.202.22

This was run in a private window, with only the following two extensions:

  • Tampermonkey (ID: dhdgffkkebhmkfjojejmpbldmpobfkfo)
    • No userscripts installed
  • Rearrange Tabs (ID: ccnnhhnmpoffieppjjkhdakcoejcpbga)

Raw output for benchmarks in this PR, ported to v2.2.8:

bench.js:46 Timer resolution detected: 1.00ms
bench.js:131 20 tests loaded
bench.js:153 Tests warmed up
bench.js:188 null test: 312 ns/op, 3,203,301-3,203,871 op/s, n = 16049967
bench.js:188 construct simpleTree: 42.1-42.2 µs/op, 23,677-23,727 op/s, n = 118749
bench.js:188 render simpleTree: 293-299 µs/op, 3,341-3,411 op/s, n = 7911
bench.js:188 add/remove simpleTree: 1.23-1.25 ms/op, 798-815 op/s, n = 3668
bench.js:188 construct nestedTree: 325 ns/op, 3,072,598-3,072,956 op/s, n = 15394614
bench.js:188 render nestedTree: 263-268 µs/op, 3,734-3,800 op/s, n = 9557
bench.js:188 add/remove nestedTree: 1.15-1.17 ms/op, 855-871 op/s, n = 3892
bench.js:188 construct mutateStylesPropertiesTree: 661-666 µs/op, 1,500-1,512 op/s, n = 7535
bench.js:188 render mutateStylesPropertiesTree: 23.7-26.8 ms/op, 37-42 op/s, n = 65
bench.js:188 add/remove mutateStylesPropertiesTree: 22.5-23.7 ms/op, 42-44 op/s, n = 194
bench.js:188 construct repeatedTree: 324 ns/op, 3,090,285-3,090,710 op/s, n = 15483396
bench.js:188 render repeatedTree: 30.3-30.4 µs/op, 32,872-32,975 op/s, n = 147169
bench.js:188 add/remove repeatedTree: 361-369 µs/op, 2,709-2,772 op/s, n = 12367
bench.js:188 construct shuffledKeyedTree: 175-177 µs/op, 5,654-5,703 op/s, n = 28453
bench.js:188 render shuffledKeyedTree: 2.91-3.33 ms/op, 300-343 op/s, n = 1186
bench.js:188 add/remove shuffledKeyedTree: 2.41-2.46 ms/op, 407-414 op/s, n = 1858
bench.js:188 mount simpleTree: 1.45-1.50 ms/op, 667-688 op/s, n = 1771
bench.js:188 redraw simpleTree: 264-269 µs/op, 3,719-3,781 op/s, n = 8396
bench.js:188 mount all: 35.4-41.5 ms/op, 24-28 op/s, n = 41
bench.js:188 redraw all: 18.3-20.1 ms/op, 49-54 op/s, n = 40
bench.js:193 Test completed in 106s

Raw output for benchmarks in this PR, using the current:

bench.js:46 Timer resolution detected: 1.00ms
bench.js:131 20 tests loaded
bench.js:153 Tests warmed up
bench.js:188 null test: 310 ns/op, 3,226,409-3,226,955 op/s, n = 16165679
bench.js:188 construct simpleTree: 24.4 µs/op, 40,955-40,993 op/s, n = 205284
bench.js:188 render simpleTree: 188-190 µs/op, 5,263-5,333 op/s, n = 13595
bench.js:188 add/remove simpleTree: 1.50-1.54 ms/op, 649-664 op/s, n = 3014
bench.js:188 construct nestedTree: 340 ns/op, 2,939,772-2,940,045 op/s, n = 14728944
bench.js:188 render nestedTree: 193-196 µs/op, 5,114-5,172 op/s, n = 13610
bench.js:188 add/remove nestedTree: 1.64-1.68 ms/op, 596-609 op/s, n = 2764
bench.js:188 construct mutateStylesPropertiesTree: 456-457 µs/op, 2,186-2,194 op/s, n = 10971
bench.js:188 render mutateStylesPropertiesTree: 27.3-29.4 ms/op, 33-36 op/s, n = 64
bench.js:188 add/remove mutateStylesPropertiesTree: 27.1-28.3 ms/op, 35-36 op/s, n = 164
bench.js:188 construct repeatedTree: 339 ns/op, 2,951,911-2,952,260 op/s, n = 14789951
bench.js:188 render repeatedTree: 34.4-34.5 µs/op, 28,975-29,098 op/s, n = 131101
bench.js:188 add/remove repeatedTree: 388-397 µs/op, 2,519-2,574 op/s, n = 11494
bench.js:188 construct shuffledKeyedTree: 316-317 µs/op, 3,152-3,164 op/s, n = 15792
bench.js:188 render shuffledKeyedTree: 2.63-2.79 ms/op, 358-380 op/s, n = 1362
bench.js:188 add/remove shuffledKeyedTree: 3.16-3.22 ms/op, 310-316 op/s, n = 1427
bench.js:188 mount simpleTree: 1.87-1.94 ms/op, 515-534 op/s, n = 1350
bench.js:188 redraw simpleTree: 187-189 µs/op, 5,280-5,344 op/s, n = 12520
bench.js:188 mount all: 39.6-44.8 ms/op, 22-25 op/s, n = 40
bench.js:188 redraw all: 23.9-31.9 ms/op, 31-41 op/s, n = 40
bench.js:193 Test completed in 109s

Note: the timer resolution displayed is the detected second-lowest interval between successive performance.now() calls, as measured over 100 calls. Brave by default rounds high precision timers to 1ms per brave/brave-core#15309, and the new benchmarking script accounts for this.

Motivation and Context

This resolves a number of ergonomic issues around the API, crossing out many long-standing feature requests and eliminating a number of gotchas.

Related issues - Fixes https://github.com//issues/1937 by enabling it to be done in userland, mostly via `m.render(elem, vnode, {removeOnThrow: true})` - Resolves https://github.com//issues/2310 by dropping `m.trust` - Resolves https://github.com//issues/2505 by not resolving routes - Resolves https://github.com//issues/2531 by not resolving routes - Fixes https://github.com//issues/2555 - Resolves https://github.com//issues/2592 by dropping `onbeforeremove` - Fixes https://github.com//issues/2621 by making each mount independent of other mount points - Fixes https://github.com//issues/2645 - Fixes https://github.com//issues/2778 - Fixes https://github.com//issues/2794 by dropping internal `ospec` - Fixes https://github.com//issues/2799 - Resolves https://github.com//issues/2802 by dropping `m.request`
Related discussions - Resolves https://github.com//discussions/2754 - Resolves https://github.com//discussions/2775 by not resolving routes - Implements https://github.com//discussions/2912 - Implements https://github.com//discussions/2915 by throwing - Resolves https://github.com//discussions/2916 by dropping `m.request` - Implements https://github.com//discussions/2917 with some minor changes - Implements https://github.com//discussions/2918 - Implements https://github.com//discussions/2919 - Implements https://github.com//discussions/2920 - Resolves https://github.com//discussions/2922 by dropping `m.request` - Resolves https://github.com//discussions/2924 by dropping `m.request` - Implements https://github.com//discussions/2925 - Resolves https://github.com//discussions/2926 by dropping `m.request` - Resolves https://github.com//discussions/2929 by not doing it (doesn't fit with the model) - Resolves https://github.com//discussions/2931 by not resolving routes - Resolves https://github.com//discussions/2934 by dropping `m.request` - Resolves https://github.com//discussions/2935 by not resolving routes - Partially implements https://github.com//discussions/2936 - Partially implements https://github.com//discussions/2937, intentionally skips rest - Resolves https://github.com//discussions/2941 by not resolving routes - Implements https://github.com//discussions/2942 by offering a configurable `route` context key - Implements https://github.com//discussions/2943 - Implements https://github.com//discussions/2945 - Resolves https://github.com//discussions/2946 by making the router a component instead (thus making it implicit) - Implements https://github.com//discussions/2948 by throwing - Resolves https://github.com//discussions/2950 by dropping `m.request`
Things this does *not* resolve - https://github.com//issues/2256 (This needs some further digging to lock down precisely what needs done) - https://github.com//issues/2315 - https://github.com//issues/2359 - https://github.com//issues/2612 - https://github.com//issues/2623 - https://github.com//issues/2643 - https://github.com//issues/2809 - https://github.com//issues/2886

New API

This is all in the style of https://mithril.js.org/api.html.

m(selector, attrs, children)

m("div.class#id", {title: "title"}, ["children"])

m.mount(element, component)

const state = {
	count: 0,
	inc() {state.count++}
}

function Counter() {
	return m("div", {onclick: state.inc}, state.count)
}

m.mount(document.body, () => m(Counter))

context.redraw()

// Option 1: use a component
function Counter(_attrs, _old, {redraw}) {
	let count = 0
	const timer = setInterval(() => {
		count++
		redraw()
	}, 1000)
	return () => [
		m("div", count),
		m.remove(() => clearInterval(timer))
	]
}

m.mount(document.body, () => m(Counter))

// Option 2: get it from the mount view callback
let count = 0

m.mount(document.body, (isInit, redraw) => {
	if (isInit) {
		setInterval(() => {
			count++
			redraw()
		}, 1000)
	}
	return m("div", count)
})

m(m.WithRouter, {prefix}, ...children)

function Home() {
	return "Welcome"
}

function App(_attrs, _old, {route}) {
	switch (route.path) {
		// defines `https://example.com/#!/home`
		case "/home": return m(Home)
		default: route.set("/home")
	}
}

// The prefix defaults to `""`, giving URLs like `https://example.com/home`
m.mount(document.body, () => m(m.WithRouter, {prefix: "#!"}, m(App)))

route.set(path)

route.set("/home")

route.current

var currentRoute = route.current

m(m.Link, ...)

m(m.Link, {href: "/Home"}, "Go to home page")

m.p(template, params)

// Issues a fetch to `PUT /api/v1/users/1?name=test`
const result = await fetch(m.p("/api/v1/users/:id", {id: 1, name: "test"}), {
	method: "PUT",
})
console.log(result)

m.q(object)

// "a=1&b=2"
var querystring = m.q({a: "1", b: "2"})

How Has This Been Tested?

I've written a bunch of new tests, and every current test (to the extent I've kept them) pass. At the time of writing, there's 8929 tests.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)

Checklist

  • My code follows the code style of this project.
  • I have added tests to cover my changes.
  • All new and existing tests passed.
  • My change requires a documentation update, and I've opened a pull request to update it already: I need to write that first, and that may be a bit.
  • I have read https://mithril.js.org/contributing.html.

Perf is somewhat improved in spots, but generally the same otherwise.
Cuts only about 2% of the bundle, but removes a pretty bad pain point in the code. Plus, inserting arbitrary HTML outside a container is usually a recipe for disaster in terms of styling.
It's simpler that way and I don't have to condition it.
It's redundant and has been for ages. A v3 increment gives me the ability to minimize surprise.
People can just use equality and regexps as needed.
Also, rename it `m` to make dev stack traces a little more readable, and remove a useless layer of indirection.
This makes that class fully monomorphic and also a bit safer.
That's not been there for years
The long comment at the top of it explains the motivation.
It's now been made redundant with `m.tracked`.
`finally` is apparently really slow. This improved benchmarks by about 3-4x, bringing them back to roughly what they were when I first cut the branch.
This matches HTML more closely, and makes for a much better experience with custom elements.
It's more predictable that way, and I expect this to break approximately nobody. (If anything, it's likely to *unbreak* some users, as they may have been assuming this to have been the case from the beginning.)
Some point along the way, this got erroneously changed and saved.
It's going to unnecessarily complicate the release process, especially now that artifacts aren't saved to the repo anymore.
@dead-claudia dead-claudia requested a review from a team as a code owner October 13, 2024 00:08
@dead-claudia dead-claudia added Type: Breaking Change For any feature request or suggestion that could reasonably break existing code Type: Enhancement For any feature request or suggestion that isn't a bug fix Type: Meta/Feedback For high-level discussion around the project and/or community itself major labels Oct 13, 2024
@dead-claudia dead-claudia added this to the v3 milestone Oct 13, 2024
@StephanHoyer
Copy link
Member

First off, thanks @dead-claudia for this massive push.

I will try to add comprehensive review hopefully this week.

@morgangrubb
Copy link

morgangrubb commented Oct 18, 2024

I'm missing a trick on how routing with params should work now?

In the past I had

  m.route.prefix = '?'
  m.route(rootElement, '/', {
    '/': { view: () => m(RootView, { context }) },
    '/clients': { view: () => m(ClientIndexView, { context }) },
    '/clients/:clientId/connect': { view: () => m(ClientConnectView, { context }) },
  })

Which worked to both generate and consume routes with params.

Now I've got

  function App(_attrs, _old, {route}) {
    switch (route.path) {
      case "/": return m(RootView, { context })
      case "/clients": return m(ClientIndexView, { context })
      case "/clients/:clientId/connect": return m(ClientConnectView, { context })
      default: route.set("/")
    }
  }

  m.mount(rootElement, () => m(m.WithRouter, { prefix: '?' }, m(App)))

But clearly that case statement won't work because :clientId is actually a changing value.

Is there a new way to specify the prefix without completely adopting this new pattern?

@dead-claudia
Copy link
Member Author

dead-claudia commented Oct 19, 2024

I'm missing a trick on how routing with params should work now?

In the past I had

  m.route.prefix = '?'
  m.route(rootElement, '/', {
    '/': { view: () => m(RootView, { context }) },
    '/clients': { view: () => m(ClientIndexView, { context }) },
    '/clients/:clientId/connect': { view: () => m(ClientConnectView, { context }) },
  })

Which worked to both generate and consume routes with params.

Now I've got

  function App(_attrs, _old, {route}) {
    switch (route.path) {
      case "/": return m(RootView, { context })
      case "/clients": return m(ClientIndexView, { context })
      case "/clients/:clientId/connect": return m(ClientConnectView, { context })
      default: route.set("/")
    }
  }

  m.mount(rootElement, () => m(m.WithRouter, { prefix: '?' }, m(App)))

But clearly that case statement won't work because :clientId is actually a changing value.

Is there a new way to specify the prefix without completely adopting this new pattern?

I used switch for ease of use in the tests, but you should idiomatically prefer if/else. It takes more space, but I've had this successfully scale out to 50+ routes, and you essentially get nested routes for free. It also takes less thinking.

function App(_attrs, _old, {route}) {
    let match

    if (route.path === "/") {
        return m(RootView, {context})
    }

    if (route.path === "/clients") {
        return m(ClientIndexView, {context})
    }

    if (match = /\/clients\/([^/]++)\/connect/.exec(route.path)) {
        return m(ClientConnectView, {context, clientId: decodeURIComponent(match[1])})
    }

    route.set("/")
}

If you wanted to completely abstract it away so you could still have route.params.clientId, you could do something like this for the /clients/:clientId/connect route:

if (match = /\/clients\/([^/]+)\/connect/.exec(route.path)) {
    route.params.set("clientId", decodeURIComponent(match[1]))
    return m(ClientConnectView, {context})
}

Be mindful that route.params in the context is a URLSearchParams object, not the dictionary object of old. Just to set expectations.

I'm thinking of adding something like if (route.match("/client/:clientId/connect")) ... that does the above implicitly.

@17dec
Copy link

17dec commented Oct 21, 2024

I was worried ES2018 might break Pale Moon, but just tested the counter example that seems to work. 👍

@panoply
Copy link

panoply commented Oct 21, 2024

I've been waiting for this proposal @dead-claudia. Been some years you've been hacking on it, cool to see it reach this level. Can't wait to get some time and dig into it.

@kfule
Copy link
Contributor

kfule commented Oct 21, 2024

Great job. I will touch it later.
Regarding performance, you might want to compare it to v2.0.4 as well, as v2.0.4 still seems to have a lot of users. Also, as mentioned in #2983, the bug fixes and improvements after v2.0.4 have caused a performance regression.

@StephanHoyer
Copy link
Member

StephanHoyer commented Oct 24, 2024

First, thanks again @dead-claudia for the work you put into that PR.

I really like the way you're heading with the move away from magic-props to special vnodes. This will greatly improve composability on the lifecycle front.

I still don't fully understand, how some of the new special vnodes work, esp. m.retain, m.set, m.use, m.init, m.throttler and m.debouncer. Maybe you can give a few more examples on this.

What I'm skeptical about

  • drop of m.request: I really like the api and flexibility
  • m.q/m.p: why abbreviate that?
  • the new style of routing (but not sure here, because I think I do not fully grasp that)

Another thing that I'm worried about is the amount of breaking changes and the backwards compatibility. As you might have noticed the mithril community is not in a great shape. Most of the main contributors (community and code) have moved away to other projects and a lot of the remaining ones have build massive apps that rely on a stable API, which is a big strength of mithril and allows for such big projects.
So a massive change in the API might not help them and drive them further away from the community.
And for new users I'm not sure if the new (more academic) API is easier to understand then the old one, which was much more straightforward.

So maybe it would be a good idea to keep some of the old ergonomics and deprecate them for at least one major version, so people have time to switch to the newer way of doing stuff. And maybe it turns out that it does not live up to it's expectations and all people like the old API more.

I would at least keep m.redraw, m.route and m.request and the lifecycle-attrs. Sure this would mean a few kb more, but who cares if we have 6kb or 10kb. They also can partly live in a separate compat-package.

So hope this helps.

@StephanHoyer
Copy link
Member

One thing I forgot to ask:

What's the SSR story of v3? I would really like to discontinue mithril-node-render and see it integrated into the main framework. And the question the comes next: How is the support for hydration of SSR-Apps?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
major Type: Breaking Change For any feature request or suggestion that could reasonably break existing code Type: Enhancement For any feature request or suggestion that isn't a bug fix Type: Meta/Feedback For high-level discussion around the project and/or community itself
Projects
Status: High priority
Development

Successfully merging this pull request may close these issues.

6 participants