Repository: effector/effector
Pull request #1220 (2025-01-24 13:10:47)

Improve error traces


Patch:
diff --git a/src/effector/createUnit.ts b/src/effector/createUnit.ts
index 201c0f6e..6f7ef769 100644
--- a/src/effector/createUnit.ts
+++ b/src/effector/createUnit.ts
@@ -40,7 +40,7 @@ import {
   setMeta,
   getMeta,
 } from './getter'
-import {assert, deprecate} from './throw'
+import {assert, deprecate, printErrorWithStack} from './throw'
 import {DOMAIN, STORE, EVENT, MAP, STACK, REG_A} from './tag'
 import {applyTemplate} from './template'
 import {forEach} from './collection'
@@ -241,6 +241,11 @@ export function createStore<State>(
   const config = flattenConfig(props)
   const plainState = createStateRef(defaultState)
   const errorTitle = generateErrorTitle('store', config)
+  const traceError = Error()
+  if (Error.captureStackTrace) {
+    Error.captureStackTrace(traceError, createStore)
+  }
+  const storeTrace = traceError.stack
   const updates = createEvent({named: 'updates', derived: true})
   applyTemplate('storeBase', plainState)
   const plainStateId = plainState.id
@@ -373,7 +378,10 @@ export function createStore<State>(
         const isVoidUpdate = isVoid(upd)
 
         if (isVoidUpdate && !explicitSkipVoid) {
-          console.error(`${errorTitle}: ${requireExplicitSkipVoidMessage}`)
+          printErrorWithStack(
+            `${errorTitle}: ${requireExplicitSkipVoidMessage}`,
+            storeTrace,
+          )
         }
 
         return (
@@ -388,6 +396,7 @@ export function createStore<State>(
     meta: {
       ...meta,
       defaultState,
+      storeTrace,
     },
     regional: true,
   })
diff --git a/src/effector/fork/createScope.ts b/src/effector/fork/createScope.ts
index 4582b4e8..7b9f4abc 100644
--- a/src/effector/fork/createScope.ts
+++ b/src/effector/fork/createScope.ts
@@ -1,4 +1,4 @@
-import {getForkPage, getGraph} from '../getter'
+import {getForkPage, getGraph, getMeta} from '../getter'
 import {setForkPage, getPageRef, currentPage} from '../kernel'
 import {createNode} from '../createNode'
 import {calc, compute} from '../step'
@@ -86,9 +86,10 @@ export function createScope(unit?: Domain): Scope {
     node: [
       calc((_, __, stack) => {
         const forkPage = getForkPage(stack)
-        if (forkPage && stack.parent) {
-          if (isNotCombineNode(stack.parent)) {
-            forkPage.warnSerialize = true
+        const parent = stack.parent
+        if (forkPage && parent) {
+          if (isNotCombineNode(parent)) {
+            forkPage.warnSerializeTraces.add(getMeta(parent.node, 'storeTrace'))
           }
         }
       }),
@@ -121,6 +122,7 @@ export function createScope(unit?: Domain): Scope {
     handlers: {sidMap: {}, unitMap: new Map()},
     fxCount: forkInFlightCounter,
     storeChange,
+    warnSerializeTraces: new Set(),
     warnSerializeNode,
   }
   return resultScope
diff --git a/src/effector/fork/serialize.ts b/src/effector/fork/serialize.ts
index 2916c024..378db89d 100644
--- a/src/effector/fork/serialize.ts
+++ b/src/effector/fork/serialize.ts
@@ -1,6 +1,6 @@
 import type {Scope, Store} from '../unit.h'
-import {forIn, includes} from '../collection'
-import {assert, deprecate} from '../throw'
+import {forEach, forIn, includes} from '../collection'
+import {assert, deprecate, printErrorWithStack} from '../throw'
 import {traverseStores} from './util'
 import {getGraph, getMeta} from '../getter'
 
@@ -12,10 +12,13 @@ export function serialize(
   scope: Scope,
   config: {ignore?: Array<Store<any>>; onlyChanges?: boolean} = {},
 ) {
-  if (scope.warnSerialize) {
+  if (scope.warnSerializeTraces.size) {
     console.error(
-      'There is a store without sid in this scope, its value is omitted',
+      'serialize: One or more stores dont have sids, their values are omitted',
     )
+    forEach(scope.warnSerializeTraces, stack => {
+      printErrorWithStack('store should have sid or `serialize: ignore`', stack)
+    })
   }
   assert(!scope.hasSidDoubles, 'duplicate sid found in this scope')
   const ignoredStores = config.ignore ? config.ignore.map(({sid}) => sid) : []
diff --git a/src/effector/throw.ts b/src/effector/throw.ts
index b1aa379a..db719048 100644
--- a/src/effector/throw.ts
+++ b/src/effector/throw.ts
@@ -19,3 +19,12 @@ export const deprecate = (
       suggestion ? `, use ${suggestion} instead` : ''
     }`,
   )
+
+export const printErrorWithStack = (
+  message: string,
+  stack: string | undefined,
+) => {
+  const error = Error(message)
+  error.stack = stack
+  console.error(error)
+}
diff --git a/src/effector/unit.h.ts b/src/effector/unit.h.ts
index 90242f9a..e76e78ab 100644
--- a/src/effector/unit.h.ts
+++ b/src/effector/unit.h.ts
@@ -188,7 +188,7 @@ export interface Scope extends Unit {
   fxCount: Node
   storeChange: Node
   /** if any affected store is missing sid, then scope cannot be serialized correctly and data will be missing */
-  warnSerialize?: boolean
+  warnSerializeTraces: Set<string>
   warnSerializeNode: Node
 }
 


Test patch:
diff --git a/src/effector/__tests__/fork/serialize.test.ts b/src/effector/__tests__/fork/serialize.test.ts
index 1ca7eaff..7632229f 100644
--- a/src/effector/__tests__/fork/serialize.test.ts
+++ b/src/effector/__tests__/fork/serialize.test.ts
@@ -12,7 +12,7 @@ import {
   createEffect,
   attach,
 } from 'effector'
-import {muteErrors} from 'effector/fixtures'
+import {argumentHistory, muteErrors} from 'effector/fixtures'
 
 muteErrors(['onlyChanges', 'fork(domain)', 'hydrate(domain'])
 
@@ -597,17 +597,24 @@ describe('serialize: missing sids', () => {
     // equals to situation, if user forgot to configure babel-plugin
     // or did not install the sid manually
     const $store = createStore('value', {sid: ''})
+    const $storeB = createStore('value', {sid: ''})
 
     const scope = fork()
 
     allSettled($store, {scope, params: 'scope value'})
+    allSettled($storeB, {scope, params: 'scope value'})
 
     const result = serialize(scope)
     expect(result).toEqual({})
     expect(scope.getState($store)).toEqual('scope value')
-    expect(console.error).toHaveBeenCalledWith(
-      'There is a store without sid in this scope, its value is omitted',
-    )
+
+    expect(argumentHistory(console.error as any)).toMatchInlineSnapshot(`
+      Array [
+        "serialize: One or more stores dont have sids, their values are omitted",
+        [Error: store should have sid or \`serialize: ignore\`],
+        [Error: store should have sid or \`serialize: ignore\`],
+      ]
+    `)
   })
   test('serialize: throws if duplicated sids', () => {
     const a = createStore(0, {sid: 'sameSid'})
diff --git a/src/effector/__tests__/store/index.test.ts b/src/effector/__tests__/store/index.test.ts
index 0cb764ce..9190b0b8 100644
--- a/src/effector/__tests__/store/index.test.ts
+++ b/src/effector/__tests__/store/index.test.ts
@@ -374,7 +374,7 @@ describe('void skip pattern deprecation', () => {
         const store = createStore(0).on(inc, () => {})
         inc()
         expect(getWarning()).toMatchInlineSnapshot(
-          `"[store] unit 'store': undefined is used to skip updates. To allow undefined as a value provide explicit { skipVoid: false } option"`,
+          `"Error: [store] unit 'store': undefined is used to skip updates. To allow undefined as a value provide explicit { skipVoid: false } option"`,
         )
       })
       test('store.on reducer skips updates with undefined and does not warn, if store has {skipVoid: true} (only warning of the store itself is shown)', () => {
@@ -404,7 +404,7 @@ describe('void skip pattern deprecation', () => {
         sample({clock: inc, fn: () => {}, target: store})
         inc()
         expect(getWarning()).toMatchInlineSnapshot(
-          `"[store] unit 'store': undefined is used to skip updates. To allow undefined as a value provide explicit { skipVoid: false } option"`,
+          `"Error: [store] unit 'store': undefined is used to skip updates. To allow undefined as a value provide explicit { skipVoid: false } option"`,
         )
       })
       test('sample target skips updates with undefined, but shows warning, if store has {skipVoid: true}', () => {
@@ -465,7 +465,7 @@ describe('void skip pattern deprecation', () => {
         .map(x => (x > 3 ? undefined : x))
       inc(4)
       expect(getWarning()).toMatchInlineSnapshot(
-        `"[store] unit 'store → *': undefined is used to skip updates. To allow undefined as a value provide explicit { skipVoid: false } option"`,
+        `"Error: [store] unit 'store → *': undefined is used to skip updates. To allow undefined as a value provide explicit { skipVoid: false } option"`,
       )
     })
     test('store.map skips updates with undefined, but shows one warning, if used with {skipVoid: true}', () => {
@@ -518,7 +518,7 @@ describe('void skip pattern deprecation', () => {
       )
       inc(4)
       expect(getWarning()).toMatchInlineSnapshot(
-        `"[store] unit 'store': undefined is used to skip updates. To allow undefined as a value provide explicit { skipVoid: false } option"`,
+        `"Error: [store] unit 'store': undefined is used to skip updates. To allow undefined as a value provide explicit { skipVoid: false } option"`,
       )
     })
     test('combine skips updates with undefined, but shows one warning, if used with {skipVoid: true}', () => {