ReviewOS

stacks/stx

A component inside a false :if runs its onMount (hydrateComponentScopes) on a large page

#2071
Closed chris opened this 8 days ago · {{ comments.length }} {{ comments.length === 1 ? 'comment' : 'comments' }}
8 days ago

On HQ.training's /settings (stx 0.2.511), two components inside a falsy :if run their onMount on page load and on client navigation:

<div :if="shownPane === 'door-access' && doorsAllowed" data-pane="door-access">
  <DoorAccessPane />
</div>

shownPane is derived(() => pane() || 'profile') and doorsAllowed is false for the account, so the branch is never shown and its element is not in the DOM after hydration. Yet DoorAccessPane's onMount(daLoad) fires (the same for TillPane in the next :if). Stack of the request it makes:

daLoad
runMountCallbacks        runtime.js
(anonymous)              runtime.js
Array.forEach
hydrateComponentScopes   runtime.js

So hydrateComponentScopes seems to run the mount callbacks of component scopes that the server rendered inside an :if branch which the client then removes.

A minimal page with the same shape (layout, derived condition with === and &&, a component with onMount) did not reproduce it, on a full load or a client navigation, so something else on the settings page is involved (it is a large page with many panes, components and :if branches; the console also showed "cloned component setup error" and a hydration-invariant warning for another component on that load).

Expected: a component inside an :if branch that is false does not mount until the branch is shown.

HQ worked around the effect at the source of the requests (stacksjs/hq.training b9b0dc6: each pane checks the account before loading), but other pages with hidden panes would still mount and load them.

8 days ago

Root cause, fixed in 679ba29 and released as v0.2.516.

On /settings every pane sits inside a loading gate, <div :if="!loading && !loadError">, which starts false. The gate's subtree is detached before anything inside it is bound, so the inner pane conditionals like <div :if="shownPane === 'door-access' && doorsAllowed"> are still plain attributes at that point. When loading goes false, the gate's deferred pass (hydrateShownBranch) calls hydrateComponentScopes over the whole subtree before binding it. That walk bound and mounted every component root it found, including DoorAccessPane and TillPane, whose own :if had not been evaluated yet. The inner :if hid the pane a moment later, but its onMount had already run. That explains the stack (runMountCallbacks ← hydrateComponentScopes ← hydrateShownBranch), and it explains why the pane element was still in the document when the mount ran.

The setup-script half of hydrateComponentScopes already skipped roots with a pending structural ancestor (hasPendingStructuralAncestor). The bind-and-mount half did not. It skips them now. Each directive hydrates its own roots through the same function when it shows them, so a pane mounts when it is opened and not before. The same walk was also mounting the component inside a :for's source markup (a mount for something that is only a template for the rows), and that is fixed too.

Why the minimal page didn't reproduce it: with a gate that is already true at DOMContentLoaded, the page walk binds the inner conditionals before anything mounts. The gate has to start false and open later.

The launcherLabel / "cloned component setup error" was not the trigger. I tried trainee, owner, manager, front desk and trainer on full loads and on client navigation into /settings, on both stx 0.2.511 and 0.2.513, and it never appeared. Likely it was fixed by a10861f (a component in a <template :if> keeps following its caller's props, 0.2.512) or was transient dev-server state.

Regression tests: packages/stx/test/router/nested-if-component-mount.test.ts covers the gate opening over false pane conditionals, compound conditions opening later, :for rows with a false :if, and a :for source under a gate. Three of the four fail without the fix.

Sign in to comment on this issue.