5
votes

Can anyone tell me why this app is running into a "Function called outside component initialization" error? (Update: Found the cause of this specific error, but still have questions below about best practices for using rollup with svelte libraries.)

It seems to only be happening when I call getContext (or onMount, etc.) from a component (which should be allowed) inside of an {#each} loop. But only happens if I include external: ['svelte'] in the library, so this may be as much a rollup question than a Svelte question.

Here's my code (which you can clone from here and try for yourself):

  "dependencies": {                                                             
    "my-new-component": "file:packages/my-new-component", 
    …
  }

src/App.svelte:

<script>
  import { FieldArray } from "my-new-component";
  import { UsesContext } from "my-new-component";
</script>

<FieldArray let:names>
  {#each names as name, i}
    <div>{name}: <UsesContext /></div>
  {/each}
</FieldArray>

packages/my-new-component/src/FieldArray.svelte:

<script>
  let names = ['a']

  const handleClick = () => {
    names = ['a', 'b']
  }
</script>

<button on:click={handleClick}>Blow up</button>

<slot names={names} />

packages/my-new-component/src/UsesContext.svelte:

<script>
  import {setContext, getContext} from 'svelte'

  const key = {}
  setContext(key, 'context')
  let context = getContext(key)
</script>

{context}

Pretty basic stuff, right?

What am I doing wrong?

I understand that setContext can only be called synchronously during component initialization (in the top level of the <script> section) and that calling getContext/setContext or any lifecycle methods (onMount) in an async way after the component has been initialized (such as from an event handler) can lead to (and is probably the most common cause of) this error.

But I am only calling it synchronously from the top-level script of the UsesContext.svelte component ... so that can't be the problem, right?

The only thing that I am doing asynchronously is updating a let variable. But that is one thing that one is allowed to do (and is commonly done) asynchronously with Svelte, isn't it?

(Of course, this is a contrived example to make it as minimal a reproducible example as possible. In the real library that I'm working on, I'm subscribing to form.registerField from final-form, and updating the component's let variables asynchronously from that callback ... an approach which is working just fine in the current version — but causes this error when I try to use it in the way described here.)

I don't feel like I'm doing anything that is not allowed in Svelte. Am I?

Things that cause the error to go away

If I change any one of the following factors (which should not make any difference), then everything works fine:

  1. Take away the {#each} loop. (commit)

    <FieldArray let:names>
      <div>{names}</div>
      <UsesContext />
    </FieldArray>
    
  2. Update variable synchronously instead of asynchronously. (commit)

  3. Copy the UsesContext component from the library into the app and import the local copy of the component instead. (commit)

    Even though it's an identical copy of the component, it works when imported from within the app but errors when imported from the library.

  4. Use a local copy (commit) or "inline" version (commit) of the FieldArray component.

    Why doesn't it work when either of these is imported from a package? Might be related to the next factor...

  5. Removing the external: ['svelte'] from packages/my-new-component/rollup.config.js causes the error to go away. (commit)

    See "Should Svelte libraries use external: ['svelte']" below.

Why do any of those solve the problem? How are they all related?

Whose bug is it?

Is this a Svelte bug? It might be a bug related to initializing/detaching components within an {#each} loop (since it only occurred for me with that combination)...

But I suspect the problem is more directly related to the way the libraries I'm using are packaging their code (with rollup). In particular, whether they do or don't include extra copies of Svelte's internal code.

Should Svelte libraries use external: ['svelte']?

It is my understanding that when building a library, other libraries they depend on like React or Svelte should be listed both under both:

  • peerDependencies
  • external: [...]

so that a duplicate copy of React/Svelte/etc doesn't get installed under node_modules (in the case of peerDependencies) or inline as part of the dist bundle that rollup builds (in the case of rollup's external option). (See this article.)

It's probably a bigger deal to include an extra duplicate copy of a giant run-time library like React or Angular than it is to include an extra copy of the minimal run-time code used by Svelte. But it's not so much bundle size that I'm worried about as possible side effects/bugs that may result from having more than one copy of "Svelte" running around. (I've certainly run into problems like this before with React when I've had multiple instances of ReactDOM floating around.)

So why isn't the official component-template including external: ['svelte']? (And why did this comment suggest adding external: ['svelte/internal'] rather than external: ['svelte']? Who imports directly from 'svelte/internal'? Never mind, I think I discovered the answer to this part. More below.)

But why does (for example) svelte-urql use external for all of its peerDependencies/devDependencies (including svelte)? Should they not be doing that? Granted, in their case, they aren't currently including any svelte components (just helper functions and setContext) yet, so that may be why it hasn't caused them any problems yet.

Ironically, I believe it was actually this "Function called outside component initialization" error that first prompted me to add this external: ['svelte'] line.

I had noticed in my app's bundle (built using webpack) that it included multiple copies of "svelte" — and by that, I mean multiple copies of the generic functions like setContext. This concerned me, so I set out to try to figure out how to make it only include one copy of "svelte" in my bundle.

I was especially concerned when I saw multiple occurrences of let current_component;/var current_component in my app bundle.

In case you're wondering which libraries/modules these "copies" are coming from, it looks like it's these ones (comments kindly added by webpack):

  • !*** /home/…/svelte-final-form/dist/index.mjs ***! (with no external: ['svelte'])

    let current_component;
    function set_current_component(component) {
        current_component = component;
    }
    function get_current_component() {
        if (!current_component)
            throw new Error(`Function called outside component initialization`);
        return current_component;
    }
    function onMount(fn) {
        get_current_component().$$.on_mount.push(fn);
    }
    function onDestroy(fn) {
        get_current_component().$$.on_destroy.push(fn);
    }
    function setContext(key, context) {
        get_current_component().$$.context.set(key, context);
    }
    
  • !*** /home/…/my-new-component/dist/index.mjs ***! (with external: ['svelte'])

    let current_component;
    function set_current_component(component) {
        current_component = component;
    }
    
    const dirty_components = [];
    const binding_callbacks = [];
    …
    

    (function get_current_component() didn't even appear in this section, apparently because the component's script references getContext from a different, external copy of Svelte, so rollup's tree-shaking noticed that its local version of get_current_component() was unused and it didn't need to include its definition:)

    function instance$1($$self) {
        console.log("my-new-component UsesContext");
        const key = {};
        Object(svelte__WEBPACK_IMPORTED_MODULE_0__["setContext"])(key, "context");
        let context = Object(svelte__WEBPACK_IMPORTED_MODULE_0__["getContext"])(key);
        return [context];
    }
    
  • !*** ./node_modules/svelte-forms-lib/build/index.mjs ***! (with no external: ['svelte'])

    var current_component;
    
    function set_current_component(component) {
      current_component = component;
    }
    
    function get_current_component() {
      if (!current_component) throw new Error("Function called outside component initialization");
      return current_component;
    }
    
    function setContext(key, context) {
      get_current_component().$$.context.set(key, context);
    }
    
  • !*** ./node_modules/svelte-select/index.mjs ***! (with no external: ['svelte'])

    var current_component;
    
    function set_current_component(component) {
      current_component = component;
    }
    
    function get_current_component() {
      if (!current_component) throw new Error("Function called outside component initialization");
      return current_component;
    }
    
    function beforeUpdate(fn) {
      get_current_component().$$.before_update.push(fn);
    }
    
  • !*** ./node_modules/svelte/internal/index.mjs ***! (from [email protected])

    var current_component;
    
    function set_current_component(component) {
      current_component = component;
    }
    
    function get_current_component() {
      if (!current_component) throw new Error("Function called outside component initialization");
      return current_component;
    }
    
    function beforeUpdate(fn) {
      get_current_component().$$.before_update.push(fn);
    }
    
    …
    
    function setContext(key, context) {
      get_current_component().$$.context.set(key, context);
    }
    

As you can see, each copy is a slightly different version of "svelte" (depending on the version number of svelte used to build each module, and on which unused functions got removed due to tree shaking).

My original hypothesis was that the if (!current_component) throw new Error("Function called outside component initialization"); error got hit because each component/library was maintaining their own copy of current_component, so maybe when it crossed the boundary from one app's/library's component ("copy" of Svelte) to the other library's component ("copy" of Svelte), current_component was undefined in that new scope even though it was correctly set in the old scope?

I still haven't ruled this out. And that hunch is what got me going trying to eradicate those extra "copies" by adding external: ['svelte'] in the first place — to try to resolve the error.

How external: ['svelte'] affects the contents of my-new-component bundle

Here is how the output from my-new-component changes when I add external: ['svelte']:

⟫ git diff
diff --git a/dist/index.mjs b/dist/index.mjs
index a0dbbc7..01938f3 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -1,3 +1,5 @@
+import { setContext, getContext } from 'svelte';
+
 function noop() { }
 function assign(tar, src) {
     // @ts-ignore
@@ -76,17 +78,6 @@ let current_component;
 function set_current_component(component) {
     current_component = component;
 }
-function get_current_component() {
-    if (!current_component)
-        throw new Error(`Function called outside component initialization`);
-    return current_component;
-}
-function setContext(key, context) {
-    get_current_component().$$.context.set(key, context);
-}
-function getContext(key) {
-    return get_current_component().$$.context.get(key);
-}
 
 const dirty_components = [];
 const binding_callbacks = [];

At first this looked like a really good thing, because it meant that this library could reuse the setContext, getContext functions (and presumably any other Svelte API functions) from its peer dependency — the svelte package that is installed in the app's node_modules/ dir — rather than needlessly including a duplicate copy of those functions in the library's bundle.

But the more that I look into this, I wonder if that wasn't quite right. The most concerning thing is that even though some Svelte functions disappeared from my library's JS bundle, some of them — most notably set_current_component and initremained in the bundle, because my library didn't specifically import them — those are "internal" methods inserted by the Svelte compiler...

So maybe that is precisely the problem that caused the error: the init/set_current_component functions that remain in my library's bundle are referring to their own locally-scoped current_component, but the getContext/setContext that I specifically imported end up calling get_current_component from a different external copy of Svelte, which refers to a different current_component in a different scope.

Oh, so that's why this comment suggest adding external: ['svelte/internal'] rather than external: ['svelte']!

Update: Found solution to error (for this particular situation at least)!

When I tried adding 'svelte/internal' to the external list, a bunch of generic svelte functions disappeared from my library's bundle and got replaced with more Svelte imports:

+import { SvelteComponent, init, safe_not_equal, text, insert, noop, detach, create_slot, update_slot, transition_in, transition_out } from 'svelte/internal';
 import { setContext, getContext } from 'svelte';
 
-function noop() { }
-function assign(tar, src) {
 …
-let current_component;
-function set_current_component(component) {
-    current_component = component;
-}

The only lines that remain now are the generated functions (create_fragment, create_fragment$1, …) that are specific to the specific components. The bundle is super small now — 148 lines, down from 432. This is exactly what I was going for! Best of all, it causes the code to work (makes the error go away) (commit)

So I'm guessing the problem I ran into is because I only partially "externalized" svelte, so my library's bundle contained a mix of references to external copy of Svelte and internal copy of Svelte ... which couldn't see each other or share their copy of let current_component with each other.

This error is particularly troublesome because it can be caused in various ways and the error doesn't reveal what the exact cause of the problem. So of course this fix only applies to this particular cause of the error.

I'm still not sure what caused me to get this error the first time (that prompted me to add external: ['svelte']). It had to have been caused by something else before. I'm guessing I was doing something like trying to call getContext from a callback that got triggered by final-form asynchronously. If that happens again, at least I'll be better prepared and know how to solve it this time (probably move the getContext() to the top of the script tag and use stores to handle the async calbacks).

Questions

To pull this all together, here are some high-level questions that I'd really like to understand:

  • Is Svelte an exception to the general principle of that "libraries that are expected to be used by both the app and one or more of its dependencies should be listed in those dependencies' peerDependencies and external so that only one copy of those libraries ends up in the resulting app bundle? Or is that principle sound but am I just doing something wrong?

  • Is it expected/okay for there be multiple copies of current_component/get_current_component() in my app's .js bundle? Or should I be concerned to see this?

  • If there are expected to be multiple copies of current_component (in an app containing components from multiple libraries), how do the various copies of "Svelte" coordinate between themselves? Or do they not need to because each component class is self-contained?

    I might be concerned, for example, that when a component passes off to the "next Svelte instance" (its children components, I presume), the current_component/parent_component would be undefined here (but maybe that doesn't matter?):

    function init(component, options, instance, create_fragment, not_equal, props) {
      var dirty = arguments.length > 6 && arguments[6] !== undefined ? arguments[6] : [-1];
      var parent_component = current_component;
      set_current_component(component);
      …
      set_current_component(parent_component);
    }
    
  • What if the different "copies" of Svelte are actually different versions of svelte package? Couldn't that cause errors if they interact with each other but have different APIs? (Or maybe the external APIs of the component class is stable so it doesn't matter if the internal API is different?)

    • The nice thing about peerDependencies is that you only have one copy of each of them in your app. It just seems wrong to have multiple copies have a library in your app. But then I keep wondering if Svelte is an exception to that rule because it compiles components into self-contained classes (that can be used stand-alone or together) rather than needing a single run-time to tie them together into a single unified component tree like React? Does Svelte not need something like that, too, in order to handle context and stores that may cross library/component boundaries? How Svelte works is still too much of a mystery to me.
  • If there is a best practice for how a Svelte library should use external to avoid these sorts of potential problems? And if so, can we canonicalize it by including it in the component template? (I'll open an issue there.)

  • It seems very strange to have to list both 'svelte/internal' and 'svelte'. It seems like svelte/internal should be an implementation detail (of how svelte has organized its source tree internally) that consumers of svelte should not have to worry about. Why is this necessary and is there any way we could change svelte so that it's not necessary to list both?

    • I've never seen any examples of other packages that require on odd suffix like /internal when adding to externals. All the examples you see (like in the docs) are just the main library name itself:

      external: ['some-externally-required-library'],

      external: ['d3'],

      Why is svelte an exception to that usual convention?

1

1 Answers

2
votes

Not sure if it's related to Sapper but, I'm here because I encountered this issue when I moved svelte from devDependencies into dependencies in my Sapper app. The issue manifest as the Sapper App component throwing

Function called outside component initialization

tl;dr - keep svelte in devDependencies.

I believe Sapper creates svelte/internal and having both Sapper's internal copy and the regular copy (now also present when calling NODE_ENV=production yarn install) causes problems.

Thanks for the detailed writeup - I never would have thought to look in package.json for this issue!