sveltejs/svelte

Svelte component is rendering despite guard clause

KieranP opened this issue · 3 comments

Describe the bug

We deployed PR #16930 to production yesterday, and it does appear to have reduced instances of errors, but sadly we saw another case come through Rollbar today, so the PR doesn't fully resolve the issue. Issue we're seeing boils down to the following:

parent.svelte

<script lang="ts">
  const currentChildPage = $derived(
    $pageStore && pageStore.getActiveState().activeChildPage
  )
</script>

{#if currentChildPage}
  <ChildPage childPage={currentChildPage} />
{/if}

child_page.svelte

<script lang="ts">
  const { childPage } = $props()
  const singlePageItem = $derived(childPage.page_items.length === 1)
</script>

{#if singlePageItem}
  ...
{:else}
  ...
{/if}

Error in child.svelte line 3:

TypeError Cannot read properties of undefined (reading 'page_items')

So somehow, child_page.svelte is rendering with childPage == undefined, even though we wrap that childPage rendering in a {#if currentChildPage} guard.

Reproduction

Unfortunately, I am not able to replicate the issue in the playground. It is a race condition that seems to happen 5-6 times a week in a large production application.

Logs

System Info

System:
    OS: macOS 26.0.1
    CPU: (12) arm64 Apple M3 Pro
    Memory: 1.58 GB / 18.00 GB
    Shell: 5.9 - /opt/homebrew/bin/zsh
  Binaries:
    Node: 24.8.0 - /Users/kieran/.asdf/installs/nodejs/24.8.0/bin/node
    npm: 11.6.0 - /Users/kieran/.asdf/plugins/nodejs/shims/npm
    pnpm: 10.20.0 - /Users/kieran/.asdf/installs/nodejs/24.8.0/bin/pnpm
    bun: 1.3.1 - /opt/homebrew/bin/bun
    Deno: 2.5.6 - /opt/homebrew/bin/deno

Severity

annoyance

hmnd commented

@KieranP could you provide more context on pageStore is? That would help replicate this

@hmnd It's a Svelte 4 style writable store. It's rather complex, but it boils down to this (note: I haven't tested this, I took our current store and stripped a lot of unrelated code, but should be enough to get the pattern we use):

export default const pageStore = () => {
  const { subscribe, set, update } = writable([])

  const setActivePage = (pageId, childPageId) => {
    update((store) => {
      return structuredClone(store).map((page) => {
        if (page.id === pageId) {
          page.active = true
          page.child_pages.map((childPage) => {
            if (childPage.id === childPageId) {
              childPage.active = true
            } else {
              childPage.active = false
            }
          })
        } else {
          page.active = false
          page.child_pages.map((childPage) => {
            childPage.active = false
          })
        }

        return page
      })
    })
  }
  
  const getActiveState = () => {
    let activePage
    let activeChildPage

    subscribe((store) => {
      activePage = store.find((page) => page.active)
      activePage ||= store[0]

      activeChildPage = activePage?.child_pages?.find(
        (childPage) => childPage.active
      )
    })
    
    return {
      activePage,
      activeChildPage
    }
  }

  return {
    setActivePage,
    getActiveState
  }
}

As user navigates, we call pageStore.setActivePage(pageId, childPageId) to update which pages are active, that triggers $pageStore in the $derived, which then calls pageStore.getActiveState(), which returns an object with activePage and activeChildPage.

7nik commented

How is that singlePageItem used?

Technically, it's very hard to completely guard it: reading childPage in ChildPage is direct reading of currentChildPage via a getter. So if some code triggers currentChildPage becoming undefined and then any code reads singlePageItem before effects get run, the described issue happens. Note that $deriveds and stores update instantly, unlike #if.