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

Route level async scripts can finish before hydration started, which breaks reactivity. #107

Open
kanashimia opened this issue Jan 22, 2025 · 4 comments

Comments

@kanashimia
Copy link

kanashimia commented Jan 22, 2025

Inline non module script tag at the end of the body can execute after the async script tag at the header.
As far as I know there are no ordering guarantees with async scripts, so this should be guarded against somehow, route scripts should wait for hydration to start.
For me it only breaks very rarely when navigating through pages pretty fast.

Image

You can easily check that ordering matters when you change both scripts to type="module" and remove async.

Package info:

May be related to Marko 6, haven't checked if it happens on previous version.


Actually looking at it I think this is may be a bug in the vite plugin instead.

@DylanPiercey
Copy link
Contributor

@kanashimia could you elaborate on what you mean by an route script and how you've written an inline script?
Generally in Marko you should use the component lifecycle (marko 5) or script tag (marko 6) to add client side javascript. You should avoid inline script tags.

It is intentional that the scripts output from Marko Vite are async. The idea here is that we kick off the loading of the Marko runtime and your component logic as soon as possible, and the Marko runtime itself ensures that your component related code is initialized as the html streams in.

@kanashimia
Copy link
Author

Hello @DylanPiercey basically I meant this:

Image

Script tags that are auto generated.

The ordering between the execution of those scripts isn't as well defined as it should be.
Basically async script should queue a task that waits for the other script to run until completion.

And the code used to test was a simple counter like this:

<let /count = 0 />
<effect() {
setTimeout(() => count = count + 1, 10);
}/>
<p> Count: ${count}</p>

Tested Marko 5, and it works even when the execution of the scripts is reordered, so it seems like it is a bug with Marko 6 indeed.
I tested by delaying their execution manually with setTimeout.

This issue belongs to the marko repo probably, it seems script scheduling is implemented there.

@DylanPiercey
Copy link
Contributor

@kanashimia the inline script you pointed out is designed to work with the fact that the main runtime is loaded async/eagerly. Basically the inline runtime will tell Marko that it's ready to go (if the runtime is loaded) and when the runtime loads it checks to see if anything was done by the inline runtime.

In practice what this means is that if you have an <effect> (now <script>) it will run the effect as soon as both the runtime and the html are ready.

If it's not working that way then there is a bug. Could you explain a bit more what you are expecting the execution order to be and/or what specifically you see as a bug? Note Marko 5 also works like this where the runtime and the inline code co-oporate but do not block each other.

@kanashimia
Copy link
Author

kanashimia commented Jan 22, 2025

The bug is that "it breaks reactivity" if async script is run first, for example the above counter example will stop working, its count will always stay at zero. But there are no error messages in the console, just silently breaks.
That async script calls a function init() and if that function is delayed with setTimeout then the counter will continue executing as normal. Ordering here shouldn't matter.

This is that init() function at the bottom:

CLICK TO SHOW GENERATED CODE

route.marko-Bnk0msQR.js:

import { e as effect, s as state, o as on, a as attr, d as data, r as register, c as createRendererWithOwner, i as init } from "./_BvIeR73t.js";
const _logoSvg = "OMITTED";
const _template_ = "OMITTED";
const _walks_ = (
  /* get, next(1), over(1), replace, out(1), next(1), over(1), replace, out(1), next(2), get, out(2) */
  " Db%lDb%lE m"
);
const _count2_effect = effect("e0", (_scope, {
  5: count
}) => setTimeout(() => _count2(_scope, count + 1), 10));
const _count2 = /* @__PURE__ */ state(5, (_scope, count) => {
  data(_scope[2], count);
  _count2_effect(_scope);
});
const _count_effect = effect("e1", (_scope, {
  4: count2
}) => on(_scope[0], "click", function() {
  _count(_scope, console.log(count2 + 1));
}));
const _count = /* @__PURE__ */ state(4, (_scope, count2) => {
  data(_scope[1], count2);
  _count_effect(_scope);
});
function _setup_(_scope) {
  attr(_scope[3], "src", _logoSvg);
  _count(_scope, 0);
  _count2(_scope, 0);
}
const _setup$Layout1_content = (_scope) => {
  _setup_(_scope[0]);
};
register("l0", /* @__PURE__ */ createRendererWithOwner(
  `<!>${_template_}<!>`,
  /* beginChild, _Page_walks, endChild */
  `D/${_walks_}&D`,
  _setup$Layout1_content
));
init();

_BvIeR73t.js:

var port2 = /* @__PURE__ */ (() => {
  let { port1, port2: port22 } = new MessageChannel();
  return port1.onmessage = () => {
    isScheduled = false, run();
  }, port22;
})(), isScheduled;
function schedule() {
  isScheduled || (isScheduled = true, queueMicrotask(flushAndWaitFrame));
}
function flushAndWaitFrame() {
  run(), requestAnimationFrame(triggerMacroTask);
}
function triggerMacroTask() {
  port2.postMessage(0);
}
var DEFAULT_RUNTIME_ID = "M";
function onDestroy(scope) {
  let parentScope = scope.d;
  for (; parentScope && !parentScope.h?.has(scope); )
    (parentScope.h ||= /* @__PURE__ */ new Set()).add(scope), scope = parentScope, parentScope = scope.d;
}
var registeredValues = {}, Render = class {
  m = [];
  n = {};
  y = {
    _: registeredValues
  };
  constructor(renders, runtimeId, renderId) {
    this.z = renders, this.A = runtimeId, this.o = renderId, this.p = renders[renderId], this.q();
  }
  w() {
    this.p.w(), this.q();
  }
  q() {
    let data2 = this.p, serializeContext = this.y, scopeLookup = this.n, visits = data2.v, cleanupOwners = /* @__PURE__ */ new Map();
    if (visits.length) {
      let commentPrefixLen = data2.i.length, cleanupMarkers = /* @__PURE__ */ new Map();
      data2.v = [];
      let sectionEnd = (visit, scopeId = this.f, curNode = visit) => {
        let scope = scopeLookup[scopeId] ||= {}, endNode = curNode;
        for (; (endNode = endNode.previousSibling).nodeType === 8; ) ;
        scope.b = endNode;
        let startNode = scope.a ||= endNode, len = cleanupMarkers.size;
        for (let [markerScopeId, markerNode] of cleanupMarkers) {
          if (!len--) break;
          markerScopeId !== scopeId && startNode.compareDocumentPosition(markerNode) & 4 && curNode.compareDocumentPosition(markerNode) & 2 && (cleanupOwners.set("" + markerScopeId, scopeId), cleanupMarkers.delete(markerScopeId));
        }
        return cleanupMarkers.set(scopeId, visit), scope;
      };
      for (let visit of visits) {
        let commentText = visit.data, token = commentText[commentPrefixLen], scopeId = parseInt(commentText.slice(commentPrefixLen + 1)), scope = scopeLookup[scopeId] ||= {}, dataIndex = commentText.indexOf(" ") + 1, data3 = dataIndex ? commentText.slice(dataIndex) : "";
        if (token === "*")
          scope[data3] = visit.previousSibling;
        else if (token === "$")
          cleanupMarkers.set(scopeId, visit);
        else if (token === "[")
          this.f && (data3 && sectionEnd(visit), this.m.push(this.f)), this.f = scopeId, scope.a = visit;
        else if (token === "]") {
          if (scope[data3] = visit, scopeId < this.f) {
            let currParent = visit.parentNode, startNode = sectionEnd(visit).a;
            currParent && currParent !== startNode.parentNode && currParent.prepend(startNode), this.f = this.m.pop();
          }
        } else if (token === "|") {
          scope[parseInt(data3)] = visit;
          let childScopeIds = JSON.parse(
            "[" + data3.slice(data3.indexOf(" ") + 1) + "]"
          ), curNode = visit;
          for (let i = childScopeIds.length - 1; i >= 0; i--)
            curNode = sectionEnd(visit, childScopeIds[i], curNode).b;
        }
      }
    }
    let resumes = data2.r;
    if (resumes) {
      data2.r = [];
      let len = resumes.length, i = 0;
      try {
        for (isResuming = true; i < len; ) {
          let resumeData = resumes[i++];
          if (typeof resumeData == "function") {
            let scopes = resumeData(serializeContext), { $global } = scopeLookup;
            $global || (scopeLookup.$global = $global = scopes.$ || {}, $global.runtimeId = this.A, $global.renderId = this.o);
            for (let scopeId in scopes)
              if (scopeId !== "$") {
                let scope = scopes[scopeId], prevScope = scopeLookup[scopeId];
                scope.$global = $global, prevScope !== scope && (scopeLookup[scopeId] = Object.assign(
                  scope,
                  prevScope
                ));
                let cleanupOwnerId = cleanupOwners.get(scopeId);
                cleanupOwnerId && (scope.d = scopes[cleanupOwnerId], onDestroy(scope));
              }
          } else i === len || typeof resumes[i] != "string" ? delete this.z[this.o] : registeredValues[resumes[i++]](
            scopeLookup[resumeData],
            scopeLookup[resumeData]
          );
        }
      } finally {
        isResuming = false;
      }
    }
  }
}, isResuming = false;
function register(id, obj) {
  return registeredValues[id] = obj, obj;
}
function init(runtimeId = DEFAULT_RUNTIME_ID) {
  let resumeRender = (renderId) => resumeRender[renderId] = renders[renderId] = new Render(renders, runtimeId, renderId), renders;
  window[runtimeId] ? setRenders(window[runtimeId]) : Object.defineProperty(window, runtimeId, {
    configurable: true,
    set: setRenders
  });
  function setRenders(v) {
    renders = v;
    for (let renderId in v)
      resumeRender(renderId);
    Object.defineProperty(window, runtimeId, {
      configurable: true,
      value: resumeRender
    });
  }
}
var MARK = {}, CLEAN = {}, DIRTY = {};
function state(valueAccessor, fn, getIntersection) {
  let valueSignal = value(valueAccessor, fn, getIntersection), markAccessor = valueAccessor + "#", valueChangeAccessor = valueAccessor + "@";
  return (scope, valueOrOp, valueChange) => (rendering ? valueSignal(
    scope,
    valueOrOp === MARK || valueOrOp === CLEAN || valueOrOp === DIRTY || (scope[valueChangeAccessor] = valueChange) || scope[markAccessor] === void 0 ? valueOrOp : CLEAN
  ) : scope[valueChangeAccessor] ? scope[valueChangeAccessor](valueOrOp) : queueSource(scope, valueSignal, valueOrOp), valueOrOp);
}
function value(valueAccessor, fn, getIntersection) {
  let markAccessor = valueAccessor + "#", intersection2 = getIntersection && ((scope, op) => (intersection2 = getIntersection())(scope, op));
  return (scope, valueOrOp) => {
    if (valueOrOp === MARK)
      (scope[markAccessor] = (scope[markAccessor] ?? 0) + 1) === 1 && intersection2?.(scope, MARK);
    else if (valueOrOp !== DIRTY) {
      let existing = scope[markAccessor] !== void 0;
      (scope[markAccessor] ||= 1) === 1 && (valueOrOp === CLEAN || existing && scope[valueAccessor] === valueOrOp ? intersection2?.(scope, CLEAN) : (scope[valueAccessor] = valueOrOp, fn && fn(scope, valueOrOp), intersection2?.(scope, DIRTY))), scope[markAccessor]--;
    }
  };
}
function effect(id, fn) {
  return register(id, fn), (scope) => {
    queueEffect(scope, fn);
  };
}
var pendingSignals = [], pendingEffects = [], rendering = false;
function queueSource(scope, signal, value2) {
  return schedule(), rendering = true, signal(scope, MARK), rendering = false, pendingSignals.push(scope, signal, value2), value2;
}
function queueEffect(scope, fn) {
  pendingEffects.push(scope, fn);
}
function run() {
  let signals = pendingSignals, effects = pendingEffects;
  try {
    rendering = true, pendingSignals = [], runSignals(signals);
  } finally {
    rendering = false;
  }
  pendingEffects = [], runEffects(effects);
}
function runEffects(effects = pendingEffects) {
  for (let i = 0; i < effects.length; i += 2) {
    let scope = effects[i], fn = effects[i + 1];
    fn(scope, scope);
  }
}
function runSignals(signals) {
  for (let i = 0; i < signals.length; i += 3) {
    let scope = signals[
      i + 0
      /* Scope */
    ], signal = signals[
      i + 1
      /* Signal */
    ], value2 = signals[
      i + 2
      /* Value */
    ];
    signal(scope, value2);
  }
}
var elementHandlersByEvent = /* @__PURE__ */ new Map(), defaultDelegator = createDelegator();
function on(element, type, handler) {
  let handlersByElement = elementHandlersByEvent.get(type);
  handlersByElement || elementHandlersByEvent.set(type, handlersByElement = /* @__PURE__ */ new WeakMap()), handlersByElement.has(element) || defaultDelegator(element, type, handleDelegated), handlersByElement.set(element, handler);
}
function createDelegator() {
  let delegatedEventsByRoot = /* @__PURE__ */ new WeakMap();
  return function(node, type, handler) {
    let root = node.getRootNode(), delegatedEvents = delegatedEventsByRoot.get(root);
    delegatedEvents || delegatedEventsByRoot.set(root, delegatedEvents = /* @__PURE__ */ new Set()), delegatedEvents.has(type) || (delegatedEvents.add(type), root.addEventListener(type, handler, true));
  };
}
function handleDelegated(ev) {
  let target = ev.target;
  if (target) {
    let handlersByElement = elementHandlersByEvent.get(ev.type);
    if (handlersByElement.get(target)?.(ev, target), ev.bubbles)
      for (; (target = target.parentElement) && !ev.cancelBubble; )
        handlersByElement.get(target)?.(ev, target);
  }
}
var fallback = /* @__PURE__ */ document.createTextNode(""), parser = /* @__PURE__ */ new Range();
function parseHTML(html2) {
  return parser.createContextualFragment(html2);
}
function parseHTMLOrSingleNode(html2) {
  let content = parseHTML(html2);
  return content.firstChild ? content.firstChild === content.lastChild && // If the firstChild is a comment it's possible its
  // a single replaced node, in which case the walker can't replace
  // the node itself.
  content.firstChild.nodeType !== 8 ? content.firstChild : content : fallback;
}
function attr(element, name, value2) {
  setAttribute(element, name, normalizeAttrValue(value2));
}
function setAttribute(element, name, value2) {
  element.getAttribute(name) != value2 && (value2 === void 0 ? element.removeAttribute(name) : element.setAttribute(name, value2));
}
function data(node, value2) {
  let normalizedValue = normalizeString(value2);
  node.data !== normalizedValue && (node.data = normalizedValue);
}
function normalizeAttrValue(value2) {
  if (value2 || value2 === 0)
    return value2 === true ? "" : value2 + "";
}
function normalizeString(value2) {
  return value2 || value2 === 0 ? value2 + "" : "‍";
}
function trimWalkString(walkString) {
  let end = walkString.length;
  for (; walkString.charCodeAt(--end) > 47; ) ;
  return walkString.slice(0, end + 1);
}
function createRendererWithOwner(template, rawWalks, setup, getClosureSignals, getArgs) {
  let args, closureSignals, id = {}, walks = rawWalks ? /* @__PURE__ */ trimWalkString(rawWalks) : " ";
  return (owner) => ({
    t: id,
    D: template,
    C: walks,
    s: setup,
    k: _clone,
    B: owner,
    E: void 0,
    get e() {
      return args ||= getArgs?.();
    },
    get c() {
      return closureSignals ||= new Set(getClosureSignals?.());
    }
  });
}
function _clone() {
  return (this.E ||= parseHTMLOrSingleNode(
    this.D
  )).cloneNode(true);
}
export {
  attr as a,
  createRendererWithOwner as c,
  data as d,
  effect as e,
  init as i,
  on as o,
  register as r,
  state as s
};

I can try to provide test case for marko to test against ordering if really needed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants