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

Losing scope in async global effects #43

Closed
dy opened this issue Jul 30, 2019 · 9 comments
Closed

Losing scope in async global effects #43

dy opened this issue Jul 30, 2019 · 9 comments
Labels
research Decision-making, arguments, discussion, comparison, design

Comments

@dy
Copy link
Owner

dy commented Jul 30, 2019

Consider aspect

import $, {state} from 'spect'

$(el, el => {
setTimeout(() => state({x: 1}))
})

In such case, state is called in separate tick, therefore loses currentTarget and is hard to identify the aspect to rerender.

Similar situation happens with async functions, where fn continues in a separate tick, dropping the callstack. That is fixable in latest node https://thecodebarbarian.com/async-stack-traces-in-node-js-12, but impossible anywhere else.

Approaches:

Parsing callstack

Possible approach is storing aspect bodies by callsite, and figuring out "out of tick" effect calls based on if their initial callsite is contained within one of tracked aspects.
That is purely visual approach to code. It is slow-ish, non-standard-ish (stacktrace is not regulated feature) and with medium weight logic. Besides, bound or native or alike aspects, which are not serializable aspect.toString(), instantly lose source (toString gives [native code]). We can hoist up, looking for nearest triggered aspect entry, presuming that is the source of async call. But that breaks trivially when we delegate aspect handling to some external function, that can reside in a separate module.

So that can work with some limitations, similar to react hooks.

  1. The effect should be called visually from the same scope as the aspect. Effects residing literally anywhere outside of effect scope aren't going to work (until browsers learn --async-stacktrace).
  • But - that wouldn't even work with visible arguments. If setTimeout callback is something external, that's naturally out of reach.
  1. Effects cannot be incorporated into wrappers. (Wrapping effects must be registered).
  • As far as wrapper is called from aspect scope, it is detectable.
  1. Aspect functions cannot be external/native. They must be unbound, described in-place, with valid toSource.

Out of scope effects can be figured out and error thrown.

Static transform

That is likely must-do, turning generic effects into particular ones, mb wrapping them with bind.

Webworker sandboxing

Another form is creating a webworker per aspect, that would also solve the #38. Tool like https://github.com/GoogleChromeLabs/clooney/ can be to the place.
That creates a web-worker aspect, but the created sandbox loses access to surrounding environment. Besides, creating a sandbox per-aspect scope is quite heavy.
That's better left for a separate effect, eg. work, on the stage of web-components:

el => {
  let result = work(() => {
  })
}

See #38 for progress on that.

Possibly like VM, that could reproduce global context, although would require same imports as the main one... Seems like breaking fx rules isn't good idea.

Runtime sandboxing

There seems to be no other way but create a runtime sandbox, wrapping initial aspect source with bound effects

$(target, el => {
html``
})

// converted to
$(target, ((html, fx, ...) => { return (el => {
}) })(html.bind(target), fx.bind(target))  )

That's going to be the fastest and safest within others, considering that the code is just wrapped, no user input expected (life can be ironic though).
Problem with this approach is that effects referred by the aspect can have any aliased name within the current module.

So this can be used with limitations.

  • Imported effect names. Similar to limitation on react hook names.
  • The error can be displayed to avoid aliasing effect names.
  • Fn must have toSource, there's no way to wrap that keeping clean source.

VM sandboxing

Running aspect code in a separate context. In browser that's done via iframe.
It's not necessary to replace globals. We have to mock deps per-aspect.
Sounds horrendous of course, although mb possible.

Throwing/catching error to obtain async stack

The error thrown in setTimeout is thrown in another tick. It may have no access to the original scope. Even without global effects. Therefore the effects should be created by scope in this or another way, but if setTimeout intends to run it outside of the scope, there's no way to do it even in react. Same is for external functions - if an effect is run outside of scope, it naturally has no lexical access to the source scope. That isn't even possible with jquery-like refs. To have access to scope the aspect arguments must be lexically visible.

@dy
Copy link
Owner Author

dy commented Jul 30, 2019

(getting dejavus with implementing jquery-spect, exactly the thing.)

Seems that we're limited in hooks to repeat react. There's no known for me way for out-of-scope function to detect the scope (in general case) it was called from, so the effects are quite limited in use-cases, quite like react.

Switching state manually isn't useful for async calls either.

Seems that we're off with jquery-spect only, providing aspect builder plugin, and pure no-jquery version.

@dy
Copy link
Owner Author

dy commented Jul 31, 2019

jQuery code is confusing and noisy, although promising for jquery land: https://gist.github.com/dy/a556d817042c3d492455e93831fd3572.

Within all options, scope detection is the fastest and the safest.
We can hoist up looking for the first matching effect, to avoid fn.toString.
Static transform would provide robust solution.

@dy
Copy link
Owner Author

dy commented Jul 31, 2019

Ugh. That doesn't seem to be possible via stacktrace.

let log = [],
    el = document.createElement('div'),
    el2 = document.createElement('div')
  
  // invocation site - available from stack
  $(el, aspect)

  // the definition site (start of aspect) - unavailable from stack
  function aspect(el) {
    log.push(state().x)

    $(el2, el2 => {});

    (
      () => {
        $(el2, el2 => {});
        setTimeout(() => {
          $(el2, el2 => {});
          // the timeout site (topmost detectable) 
          (() => state({ x: 1 }))()
        })
      }
    )()
  }

Neither creating new function helps detecting aspect - we can get only site of function evaluator, which says nothing about exact aspect.

The new functions know nothing about imported deps, they need providing effects as arguments anyways.
So we're off with runtime sandboxing only via passing fx as args.

@dy
Copy link
Owner Author

dy commented Jul 31, 2019

Ok. Even if we try to create scoped function via scoped-function, we lose function surroundings, like imported modules. To use scoped functions, we have to indicate scope.

So seems that's proven - there's no way to provide context-less global effects directly, not changing their API (useState-like, creating local reference) or keeping reference (jQuery).

@dy dy closed this as completed Jul 31, 2019
@dy dy mentioned this issue Aug 2, 2019
@dy
Copy link
Owner Author

dy commented Aug 5, 2019

Afterthought

We could use state from timeout effect as

function app(el) {
  timeout(() => {
    state()
  })
}

In this case timeout would enable aspect before triggering callback.

There's limitation for fx though:

function app(el) {
  fx(async fn() {
    await smth()
    state()
  })
}

The async tick is guaranteed, and for state there's no way to identify target.

Why doubts in jQuery approach?

  • Because fx can be a direct effect, there's no necessity to keep it attached to holder.
  • Because route must be a global effect, there's no sense to keep it per-element.
  • Because html is first-class anonymous html constructor.

We could, actually, attach effects explicitly via deps, so that we wouldn't have to register implicit observables via reading some external state.

function app(el) {
fx(async (state, attr, id) => {
await tick()
// state is attached to current element context
state({ el: value})
}, state, attr, id)
}

That looks weird though - passing context along with attribs.

On the other side

  • Domain connectors $el.html = code look way more natural
  • They can act as holders of effects, not necessary to create global imports.

@dy dy reopened this Aug 5, 2019
@dy dy closed this as completed Aug 5, 2019
@dy
Copy link
Owner Author

dy commented Aug 6, 2019

Although - state seems to be the only bottleneck.
If we redesign it as

let state = useState(defaults)
//...
setTimeout(()=>{
state.x = 1
})

@dy dy added the research Decision-making, arguments, discussion, comparison, design label Aug 29, 2019
@dy dy changed the title Losing scope Losing scope in async global effects Sep 7, 2019
@dy
Copy link
Owner Author

dy commented Sep 7, 2019

Getting back to this lovely approach.
jQuery-refs way has a set of disadvantages, most of all, confusing VDOM construction and $ mess: #80+.
If we actually provide all possible async effects #66 (comment), #67, we can go on with this approach. Global effects are valuable piece.

The only issue remains then - how to apply context.

use.call(target, el => {
   state.id = 1

  fx(() => {
    attr(a => a.loading = true)(el)
    attr.loading = true
    state.user = await ky.get`./api/user/${ state('id') }`
    attr.loading = false
  }, [id])

  html`<p use=${i18n}>${
    attr.loading ? `Hello, ${ state.user.name }!` : `Thanks for patience...`
  }</p>`

  use(() => {
    // like that probably?
    // but how do we switch context back? eg. read initial state?
    // mb contexts are not for that? eg. use(anotherAspect)
    // or manual indeed? ctxStart(), ctxEnd()?
  }, externalEl)

  css`...`
})

Although even getContext('2d') is basically an extension of element functionality. In other words we do

let elCtx = el.getContext('spect')
// or
let $el = context(el)
// or
let $el = spect(el)

So we just build a platform of contexts.

@dy
Copy link
Owner Author

dy commented Sep 20, 2019

Couple more reasons against jquery-refs. Yes, we add chaining, but.

  • we have to create multiple wrappers.
  • we can't easily store global state for example.
  • we can't easily wrap any target
  • it creates collection/single instance confusion
  • having effect(target, params) would be just natural atomic pattern, allowing to wrap anything
  • having effect(target) would enable FP style naturally
  • there's still conflict importing global effects vs getting them via local instance
  • there's conflict with real jQuery
  • for every effect in jquery we anyways call $target.fx(params) - not much effort is saved vs fx(target, params) - but the whole concept of wrappers is introduced, instead of centralized handling elements/collections without external stuff
  • spect wrapper is unfortunately ugly-ish in console
  • direct effects resolves issue of components
html(target, h`${ data.map(tpl) }`)
const tpl = data => h`...`

html(target, h`${ data.map(item => h`<${Comp} ...${item}/>`) }`)
const comp = el => html(el)`<${}/>`
  • that is lovely and natural to change(target, how)

@dy dy reopened this Sep 20, 2019
This was referenced Sep 21, 2019
@dy
Copy link
Owner Author

dy commented Sep 21, 2019

Ok, that's a separate ticket.

@dy dy closed this as completed Sep 21, 2019
@dy dy mentioned this issue Sep 21, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
research Decision-making, arguments, discussion, comparison, design
Projects
None yet
Development

No branches or pull requests

1 participant