@dfosco/storyboard-react 4.2.0-alpha.14 → 4.2.0-alpha.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Tests for iframe snapshot display — single snapshot prop.
3
3
  */
4
- import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
5
- import { render, fireEvent, waitFor, act } from '@testing-library/react'
4
+ import { describe, it, expect, vi, afterEach } from 'vitest'
5
+ import { render } from '@testing-library/react'
6
6
  import PrototypeEmbed from './PrototypeEmbed.jsx'
7
7
  import StoryWidget from './StoryWidget.jsx'
8
8
 
@@ -54,9 +54,9 @@ afterEach(() => {
54
54
  document.querySelectorAll('[data-sb-canvas-theme]').forEach(el => el.remove())
55
55
  })
56
56
 
57
- describe('Snapshot display', () => {
57
+ describe('Snapshot display (snapshots removed — iframes always render)', () => {
58
58
  describe('PrototypeEmbed', () => {
59
- it('shows snapshot image when valid snapshot prop exists', () => {
59
+ it('renders iframe even when snapshot prop is provided', () => {
60
60
  const { wrapper } = renderInCanvas(
61
61
  <PrototypeEmbed
62
62
  id="proto-abc123"
@@ -72,13 +72,11 @@ describe('Snapshot display', () => {
72
72
  />
73
73
  )
74
74
 
75
- const img = wrapper.querySelector('img')
76
- expect(img).toBeInTheDocument()
77
- expect(img.src).toContain('snapshot-proto-abc123.webp')
78
- expect(wrapper.querySelector('iframe')).not.toBeInTheDocument()
75
+ expect(wrapper.querySelector('img')).not.toBeInTheDocument()
76
+ expect(wrapper.querySelector('iframe')).toBeInTheDocument()
79
77
  })
80
78
 
81
- it('falls back to snapshotLight for backward compat', () => {
79
+ it('renders iframe when snapshotLight prop is provided', () => {
82
80
  const { wrapper } = renderInCanvas(
83
81
  <PrototypeEmbed
84
82
  id="proto-abc123"
@@ -94,12 +92,11 @@ describe('Snapshot display', () => {
94
92
  />
95
93
  )
96
94
 
97
- const img = wrapper.querySelector('img')
98
- expect(img).toBeInTheDocument()
99
- expect(img.src).toContain('snapshot-proto-abc123--light.webp')
95
+ expect(wrapper.querySelector('img')).not.toBeInTheDocument()
96
+ expect(wrapper.querySelector('iframe')).toBeInTheDocument()
100
97
  })
101
98
 
102
- it('shows placeholder when no snapshot exists', () => {
99
+ it('renders iframe when no snapshot exists', () => {
103
100
  const { wrapper } = renderInCanvas(
104
101
  <PrototypeEmbed
105
102
  id="proto-xyz"
@@ -110,32 +107,10 @@ describe('Snapshot display', () => {
110
107
  )
111
108
 
112
109
  expect(wrapper.querySelector('img')).not.toBeInTheDocument()
113
- expect(wrapper.querySelector('iframe')).not.toBeInTheDocument()
114
- })
115
-
116
- it('falls back to placeholder when snapshot image fails to load', () => {
117
- const { wrapper } = renderInCanvas(
118
- <PrototypeEmbed
119
- id="proto-abc123"
120
- props={{
121
- src: '/test',
122
- width: 400,
123
- height: 300,
124
- zoom: 100,
125
- snapshot: '/_storyboard/canvas/images/snapshot-proto-abc123.webp?v=123',
126
- }}
127
- onUpdate={vi.fn()}
128
- resizable={false}
129
- />
130
- )
131
-
132
- const img = wrapper.querySelector('img')
133
- expect(img).toBeInTheDocument()
134
- fireEvent.error(img)
135
- expect(wrapper.querySelector('img')).not.toBeInTheDocument()
110
+ expect(wrapper.querySelector('iframe')).toBeInTheDocument()
136
111
  })
137
112
 
138
- it('ignores snapshot that does not match widget ID', () => {
113
+ it('ignores snapshot prop that does not match widget ID', () => {
139
114
  const { wrapper } = renderInCanvas(
140
115
  <PrototypeEmbed
141
116
  id="proto-abc123"
@@ -152,9 +127,10 @@ describe('Snapshot display', () => {
152
127
  )
153
128
 
154
129
  expect(wrapper.querySelector('img')).not.toBeInTheDocument()
130
+ expect(wrapper.querySelector('iframe')).toBeInTheDocument()
155
131
  })
156
132
 
157
- it('does not show snapshot for external URLs', () => {
133
+ it('renders iframe for external URLs regardless of snapshot', () => {
158
134
  const { wrapper } = renderInCanvas(
159
135
  <PrototypeEmbed
160
136
  id="proto-ext"
@@ -171,11 +147,12 @@ describe('Snapshot display', () => {
171
147
  )
172
148
 
173
149
  expect(wrapper.querySelector('img')).not.toBeInTheDocument()
150
+ expect(wrapper.querySelector('iframe')).toBeInTheDocument()
174
151
  })
175
152
  })
176
153
 
177
154
  describe('StoryWidget', () => {
178
- it('shows snapshot image when valid snapshot prop exists', () => {
155
+ it('renders iframe even when snapshot prop is provided', () => {
179
156
  const { wrapper } = renderInCanvas(
180
157
  <StoryWidget
181
158
  id="story-abc123"
@@ -191,13 +168,11 @@ describe('Snapshot display', () => {
191
168
  />
192
169
  )
193
170
 
194
- const img = wrapper.querySelector('img')
195
- expect(img).toBeInTheDocument()
196
- expect(img.src).toContain('snapshot-story-abc123.webp')
197
- expect(wrapper.querySelector('iframe')).not.toBeInTheDocument()
171
+ expect(wrapper.querySelector('img')).not.toBeInTheDocument()
172
+ expect(wrapper.querySelector('iframe')).toBeInTheDocument()
198
173
  })
199
174
 
200
- it('falls back to snapshotDark for backward compat', () => {
175
+ it('renders iframe when snapshotDark prop is provided', () => {
201
176
  const { wrapper } = renderInCanvas(
202
177
  <StoryWidget
203
178
  id="story-abc123"
@@ -210,50 +185,27 @@ describe('Snapshot display', () => {
210
185
  />
211
186
  )
212
187
 
213
- const img = wrapper.querySelector('img')
214
- expect(img).toBeInTheDocument()
215
- expect(img.src).toContain('snapshot-story-abc123--dark.webp')
216
- })
217
-
218
- it('shows placeholder when no snapshot exists', () => {
219
- const { wrapper } = renderInCanvas(
220
- <StoryWidget
221
- id="story-xyz"
222
- props={{
223
- storyId: 'button-patterns',
224
- exportName: 'Primary',
225
- width: 400,
226
- height: 300,
227
- }}
228
- onUpdate={vi.fn()}
229
- resizable={false}
230
- />
231
- )
232
-
233
188
  expect(wrapper.querySelector('img')).not.toBeInTheDocument()
234
- expect(wrapper.querySelector('iframe')).not.toBeInTheDocument()
189
+ expect(wrapper.querySelector('iframe')).toBeInTheDocument()
235
190
  })
236
191
 
237
- it('falls back to placeholder when snapshot image fails to load', () => {
192
+ it('renders iframe when no snapshot exists', () => {
238
193
  const { wrapper } = renderInCanvas(
239
194
  <StoryWidget
240
- id="story-abc123"
195
+ id="story-xyz"
241
196
  props={{
242
197
  storyId: 'button-patterns',
243
198
  exportName: 'Primary',
244
199
  width: 400,
245
200
  height: 300,
246
- snapshot: '/_storyboard/canvas/images/snapshot-story-abc123.webp?v=456',
247
201
  }}
248
202
  onUpdate={vi.fn()}
249
203
  resizable={false}
250
204
  />
251
205
  )
252
206
 
253
- const img = wrapper.querySelector('img')
254
- expect(img).toBeInTheDocument()
255
- fireEvent.error(img)
256
207
  expect(wrapper.querySelector('img')).not.toBeInTheDocument()
208
+ expect(wrapper.querySelector('iframe')).toBeInTheDocument()
257
209
  })
258
210
  })
259
211
  })
@@ -0,0 +1,19 @@
1
+ import { useSyncExternalStore } from 'react'
2
+
3
+ /**
4
+ * Returns true when embed refreshes are paused globally.
5
+ * Tracks the `storyboard-embeds-paused` class on <html>.
6
+ */
7
+ function subscribe(callback) {
8
+ const observer = new MutationObserver(callback)
9
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
10
+ return () => observer.disconnect()
11
+ }
12
+
13
+ function getSnapshot() {
14
+ return document.documentElement.classList.contains('storyboard-embeds-paused')
15
+ }
16
+
17
+ export function useEmbedsPaused() {
18
+ return useSyncExternalStore(subscribe, getSnapshot, () => false)
19
+ }
@@ -0,0 +1,14 @@
1
+ import { useSyncExternalStore, useCallback } from 'react'
2
+ import { getConfig, subscribeToConfig, getConfigSnapshot } from '@dfosco/storyboard-core'
3
+
4
+ /**
5
+ * React hook for reading from the unified config store.
6
+ *
7
+ * @param {string} [domain] - Optional domain key (e.g. 'toolbar', 'canvas')
8
+ * @returns {object} The config object (full or domain slice)
9
+ */
10
+ export function useConfig(domain) {
11
+ const snapshot = useSyncExternalStore(subscribeToConfig, getConfigSnapshot)
12
+ // eslint-disable-next-line react-hooks/exhaustive-deps
13
+ return useCallback(() => getConfig(domain), [snapshot, domain])()
14
+ }
package/src/index.js CHANGED
@@ -25,6 +25,7 @@ export { useUndoRedo } from './hooks/useUndoRedo.js'
25
25
  export { useFeatureFlag } from './hooks/useFeatureFlag.js'
26
26
  export { useMode } from './hooks/useMode.js'
27
27
  export { useThemeState, useThemeSyncTargets } from './hooks/useThemeState.js'
28
+ export { useConfig } from './hooks/useConfig.js'
28
29
 
29
30
  // React Router integration
30
31
  export { installHashPreserver } from './hashPreserver.js'
@@ -526,6 +526,166 @@ function readModesConfig(root) {
526
526
  return fallback
527
527
  }
528
528
 
529
+ /**
530
+ * Read a JSON/JSONC file, returning null on failure.
531
+ */
532
+ function readJsonFile(filePath) {
533
+ try {
534
+ const raw = fs.readFileSync(filePath, 'utf-8')
535
+ const errors = []
536
+ const parsed = parseJsonc(raw, errors)
537
+ return errors.length === 0 ? parsed : null
538
+ } catch {
539
+ return null
540
+ }
541
+ }
542
+
543
+ /**
544
+ * Find a core config file from either the monorepo workspace or node_modules.
545
+ */
546
+ function readCoreConfigFile(root, filename) {
547
+ const candidates = [
548
+ path.resolve(root, `packages/core/${filename}`),
549
+ path.resolve(root, `node_modules/@dfosco/storyboard-core/${filename}`),
550
+ ]
551
+ for (const p of candidates) {
552
+ const parsed = readJsonFile(p)
553
+ if (parsed) return parsed
554
+ }
555
+ return null
556
+ }
557
+
558
+ /**
559
+ * Deep-merge helper (same as loader.js deepMerge but available at build time).
560
+ * Arrays are replaced, not concatenated. Objects are recursively merged.
561
+ */
562
+ function deepMergeBuild(target, source) {
563
+ if (!source || typeof source !== 'object') return target
564
+ if (!target || typeof target !== 'object') return source
565
+ const result = { ...target }
566
+ for (const key of Object.keys(source)) {
567
+ const sv = source[key]
568
+ const tv = target[key]
569
+ if (sv && typeof sv === 'object' && !Array.isArray(sv) && tv && typeof tv === 'object' && !Array.isArray(tv)) {
570
+ result[key] = deepMergeBuild(tv, sv)
571
+ } else {
572
+ result[key] = sv
573
+ }
574
+ }
575
+ return result
576
+ }
577
+
578
+ /**
579
+ * Build the unified config object by reading and merging all config sources.
580
+ *
581
+ * Priority (lowest → highest):
582
+ * core defaults → user widgets → user paste → user toolbar → user commandpalette → storyboard.config.json
583
+ *
584
+ * Returns { unified, warnings } where warnings is an array of overlap messages.
585
+ */
586
+ function buildUnifiedConfig(root) {
587
+ const warnings = []
588
+
589
+ // 1. Read core defaults
590
+ const coreToolbar = readCoreConfigFile(root, 'toolbar.config.json') || {}
591
+ const coreCommandPalette = readCoreConfigFile(root, 'commandpalette.config.json') || {}
592
+ const corePaste = readCoreConfigFile(root, 'paste.config.json') || {}
593
+ const coreWidgets = readCoreConfigFile(root, 'widgets.config.json') || {}
594
+
595
+ // 2. Read user config files (priority order)
596
+ const userFiles = [
597
+ { domain: 'widgets', filename: 'widgets.config.json', priority: 1 },
598
+ { domain: 'paste', filename: 'paste.config.json', priority: 2 },
599
+ { domain: 'toolbar', filename: 'toolbar.config.json', priority: 3 },
600
+ { domain: 'commandPalette', filename: 'commandpalette.config.json', priority: 4 },
601
+ ]
602
+
603
+ const userConfigs = {}
604
+ for (const { domain, filename } of userFiles) {
605
+ const filePath = path.resolve(root, filename)
606
+ const parsed = readJsonFile(filePath)
607
+ if (parsed) userConfigs[domain] = { data: parsed, filename }
608
+ }
609
+
610
+ // 3. Read storyboard.config.json (highest priority)
611
+ // Use the schema-defaulted config for most things, but also read
612
+ // the raw file to know which keys were explicitly set by the user.
613
+ const { config: sbConfig } = readConfig(root)
614
+ const rawSbConfig = readJsonFile(path.resolve(root, 'storyboard.config.json')) || {}
615
+
616
+ // 4. Merge core defaults with user overrides per domain
617
+ const toolbar = userConfigs.toolbar
618
+ ? deepMergeBuild(coreToolbar, userConfigs.toolbar.data)
619
+ : coreToolbar
620
+ const commandPalette = userConfigs.commandPalette
621
+ ? deepMergeBuild(coreCommandPalette, userConfigs.commandPalette.data)
622
+ : coreCommandPalette
623
+ const paste = userConfigs.paste
624
+ ? deepMergeBuild(corePaste, userConfigs.paste.data)
625
+ : corePaste
626
+ const widgets = userConfigs.widgets
627
+ ? deepMergeBuild(coreWidgets, userConfigs.widgets.data)
628
+ : coreWidgets
629
+
630
+ // 5. Apply storyboard.config.json overrides (highest priority for all domains)
631
+ // Only merge when the user explicitly defined the key in storyboard.config.json
632
+ // (not from configSchema defaults, which would overwrite core config with empty arrays).
633
+ const finalToolbar = rawSbConfig.toolbar
634
+ ? deepMergeBuild(toolbar, sbConfig.toolbar)
635
+ : toolbar
636
+ const finalCommandPalette = rawSbConfig.commandPalette
637
+ ? deepMergeBuild(commandPalette, sbConfig.commandPalette)
638
+ : commandPalette
639
+
640
+ // 6. Detect overlaps between user config files and storyboard.config.json
641
+ if (rawSbConfig.toolbar && userConfigs.toolbar) {
642
+ const overlaps = findOverlappingKeys(userConfigs.toolbar.data, rawSbConfig.toolbar)
643
+ for (const key of overlaps) {
644
+ warnings.push(`Config overlap: "${key}" is defined in both toolbar.config.json and storyboard.config.json.toolbar — storyboard.config.json wins.`)
645
+ }
646
+ }
647
+ if (rawSbConfig.commandPalette && userConfigs.commandPalette) {
648
+ const overlaps = findOverlappingKeys(userConfigs.commandPalette.data, rawSbConfig.commandPalette)
649
+ for (const key of overlaps) {
650
+ warnings.push(`Config overlap: "${key}" is defined in both commandpalette.config.json and storyboard.config.json.commandPalette — storyboard.config.json wins.`)
651
+ }
652
+ }
653
+
654
+ // 7. Build the unified config object
655
+ const unified = {
656
+ toolbar: finalToolbar,
657
+ commandPalette: finalCommandPalette,
658
+ paste,
659
+ widgets,
660
+ featureFlags: sbConfig?.featureFlags || {},
661
+ modes: sbConfig?.modes || {},
662
+ ui: sbConfig?.ui || {},
663
+ canvas: sbConfig?.canvas || {},
664
+ comments: sbConfig?.comments || {},
665
+ customerMode: sbConfig?.customerMode || {},
666
+ plugins: sbConfig?.plugins || {},
667
+ repository: sbConfig?.repository || {},
668
+ workshop: sbConfig?.workshop || {},
669
+ }
670
+
671
+ return { unified, warnings }
672
+ }
673
+
674
+ /**
675
+ * Find top-level keys that exist in both objects (overlap detection).
676
+ */
677
+ function findOverlappingKeys(a, b, prefix = '') {
678
+ const overlaps = []
679
+ if (!a || !b || typeof a !== 'object' || typeof b !== 'object') return overlaps
680
+ for (const key of Object.keys(a)) {
681
+ if (key in b) {
682
+ const path = prefix ? `${prefix}.${key}` : key
683
+ overlaps.push(path)
684
+ }
685
+ }
686
+ return overlaps
687
+ }
688
+
529
689
  function generateModule({ index, protoFolders, flowRoutes, canvasRoutes, canvasAliases, canvasGroups, storyRoutes }, root) {
530
690
  const declarations = []
531
691
  const INDEX_KEYS = ['flow', 'object', 'record', 'prototype', 'folder', 'canvas']
@@ -585,18 +745,28 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes, canvasA
585
745
  parsed = { ...parsed, folder: protoFolders[name] }
586
746
  }
587
747
 
588
- // Load toolbar.config.json from prototype directory if present
748
+ // Load prototype-level config overrides from the prototype directory.
749
+ // Any config file placed alongside the .prototype.json becomes an override
750
+ // for that domain when the prototype is active.
589
751
  if (suffix === 'prototype') {
590
752
  const protoDir = path.dirname(absPath)
591
- const toolbarConfigPath = path.join(protoDir, 'toolbar.config.json')
592
- if (fs.existsSync(toolbarConfigPath)) {
593
- try {
594
- const toolbarRaw = fs.readFileSync(toolbarConfigPath, 'utf-8')
595
- const toolbarConfig = parseJsonc(toolbarRaw)
596
- if (toolbarConfig) {
597
- parsed = { ...parsed, toolbarConfig }
598
- }
599
- } catch { /* skip invalid toolbar config */ }
753
+ const protoConfigFiles = [
754
+ { filename: 'toolbar.config.json', key: 'toolbarConfig' },
755
+ { filename: 'commandpalette.config.json', key: 'commandPaletteConfig' },
756
+ { filename: 'widgets.config.json', key: 'widgetsConfig' },
757
+ { filename: 'paste.config.json', key: 'pasteConfig' },
758
+ ]
759
+ for (const { filename, key } of protoConfigFiles) {
760
+ const cfgPath = path.join(protoDir, filename)
761
+ if (fs.existsSync(cfgPath)) {
762
+ try {
763
+ const raw = fs.readFileSync(cfgPath, 'utf-8')
764
+ const cfg = parseJsonc(raw)
765
+ if (cfg) {
766
+ parsed = { ...parsed, [key]: cfg }
767
+ }
768
+ } catch { /* skip invalid config */ }
769
+ }
600
770
  }
601
771
  }
602
772
 
@@ -694,6 +864,14 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes, canvasA
694
864
  const imports = [`import { init } from '@dfosco/storyboard-core'`]
695
865
  const initCalls = [`init({ flows, objects, records, prototypes, folders, canvases, stories })`]
696
866
 
867
+ // Build unified config from all sources
868
+ const { unified: unifiedConfig, warnings: configWarnings } = buildUnifiedConfig(root)
869
+ for (const w of configWarnings) {
870
+ console.warn(`[storyboard] ⚠ ${w}`)
871
+ }
872
+ imports.push(`import { initConfig } from '@dfosco/storyboard-core'`)
873
+ initCalls.push(`initConfig(${JSON.stringify(unifiedConfig)})`)
874
+
697
875
  // Feature flags from storyboard.config.json
698
876
  const { config } = readConfig(root)
699
877
  if (config?.featureFlags && Object.keys(config.featureFlags).length > 0) {
@@ -737,6 +915,20 @@ function generateModule({ index, protoFolders, flowRoutes, canvasRoutes, canvasA
737
915
  initCalls.push(`initCustomerModeConfig(${JSON.stringify(config.customerMode)})`)
738
916
  }
739
917
 
918
+ // Client toolbar overrides from root toolbar.config.json
919
+ const clientToolbarPath = path.resolve(root, 'toolbar.config.json')
920
+ try {
921
+ if (fs.existsSync(clientToolbarPath)) {
922
+ const raw = fs.readFileSync(clientToolbarPath, 'utf-8')
923
+ const errors = []
924
+ const parsed = parseJsonc(raw, errors)
925
+ if (parsed && errors.length === 0) {
926
+ imports.push(`import { setClientToolbarOverrides } from '@dfosco/storyboard-core'`)
927
+ initCalls.push(`setClientToolbarOverrides(${JSON.stringify(parsed)})`)
928
+ }
929
+ }
930
+ } catch { /* skip if unreadable */ }
931
+
740
932
  // Log info when multiple flows target the same route
741
933
  const routeGroups = {}
742
934
  for (const [name, { route, isDefault }] of Object.entries(resolvedFlowRoutes)) {
@@ -974,8 +1166,21 @@ export default function storyboardDataPlugin() {
974
1166
  return
975
1167
  }
976
1168
 
977
- // Invalidate when toolbar.config.json inside a prototype changes
978
- if (normalized.endsWith('/toolbar.config.json') && normalized.includes('/prototypes/')) {
1169
+ // Invalidate when any config file inside a prototype changes
1170
+ const protoConfigPattern = /\/(toolbar|commandpalette|widgets|paste)\.config\.json$/
1171
+ if (protoConfigPattern.test(normalized) && normalized.includes('/prototypes/')) {
1172
+ buildResult = null
1173
+ const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
1174
+ if (mod) {
1175
+ server.moduleGraph.invalidateModule(mod)
1176
+ server.ws.send({ type: 'full-reload' })
1177
+ }
1178
+ return
1179
+ }
1180
+
1181
+ // Invalidate when root toolbar.config.json changes
1182
+ if (normalized === path.resolve(root, 'toolbar.config.json').split(path.sep).join('/') ||
1183
+ normalized === path.resolve(root, 'toolbar.config.json')) {
979
1184
  buildResult = null
980
1185
  const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
981
1186
  if (mod) {
@@ -989,6 +1194,9 @@ export default function storyboardDataPlugin() {
989
1194
  // Also invalidate when files are added/removed inside .folder/ directories
990
1195
  const inFolder = normalized.includes('.folder/')
991
1196
  if (!parsed && !inFolder) return
1197
+ // Source files inside .folder/ dirs (jsx, css, etc.) are handled by
1198
+ // Vite's built-in HMR / React Fast Refresh — don't full-reload for them.
1199
+ if (!parsed && inFolder) return
992
1200
  // Rebuild index and invalidate virtual module
993
1201
  buildResult = null
994
1202
  const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
@@ -1002,6 +1210,9 @@ export default function storyboardDataPlugin() {
1002
1210
  const parsed = parseDataFile(filePath)
1003
1211
  const inFolder = filePath.replace(/\\/g, '/').includes('.folder/')
1004
1212
  if (!parsed && !inFolder) return
1213
+ // Source files (jsx, css, etc.) inside .folder/ dirs are handled by
1214
+ // Vite's built-in HMR — don't trigger a full-reload for them.
1215
+ if (!parsed && inFolder) return
1005
1216
 
1006
1217
  // Canvas writers/editors can emit unlink+add for an in-place save.
1007
1218
  // Treat canvas add/unlink as runtime data updates and never full-reload
@@ -1097,8 +1308,14 @@ export default function storyboardDataPlugin() {
1097
1308
  // Watch storyboard.config.json for changes
1098
1309
  const { configPath } = readConfig(root)
1099
1310
  watcher.add(configPath)
1311
+
1312
+ // Watch root toolbar.config.json for changes
1313
+ const clientToolbarConfigPath = path.resolve(root, 'toolbar.config.json')
1314
+ watcher.add(clientToolbarConfigPath)
1315
+
1100
1316
  const invalidateConfig = (filePath) => {
1101
- if (path.resolve(filePath) === configPath) {
1317
+ const resolved = path.resolve(filePath)
1318
+ if (resolved === configPath || resolved === clientToolbarConfigPath) {
1102
1319
  buildResult = null
1103
1320
  const mod = server.moduleGraph.getModuleById(RESOLVED_ID)
1104
1321
  if (mod) {