@dfosco/storyboard-react 2.0.0 → 2.2.0

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,11 +1,12 @@
1
- import { useMemo, useSyncExternalStore } from 'react'
1
+ import { useContext, useMemo, useSyncExternalStore } from 'react'
2
2
  import { useParams } from 'react-router-dom'
3
- import { loadRecord } from '@dfosco/storyboard-core'
3
+ import { loadRecord, resolveRecordName } from '@dfosco/storyboard-core'
4
4
  import { deepClone, setByPath } from '@dfosco/storyboard-core'
5
5
  import { getAllParams } from '@dfosco/storyboard-core'
6
6
  import { isHideMode, getAllShadows } from '@dfosco/storyboard-core'
7
7
  import { subscribeToHash, getHashSnapshot } from '@dfosco/storyboard-core'
8
8
  import { subscribeToStorage, getStorageSnapshot } from '@dfosco/storyboard-core'
9
+ import { StoryboardContext } from '../StoryboardContext.js'
9
10
 
10
11
  /**
11
12
  * Collect overrides for a record and merge them into the base array.
@@ -19,15 +20,26 @@ import { subscribeToStorage, getStorageSnapshot } from '@dfosco/storyboard-core'
19
20
  * - Unknown ids create new entries appended to the array.
20
21
  *
21
22
  * @param {Array} baseRecords - The original record array (will be deep-cloned)
22
- * @param {string} recordName - Record collection name (e.g. "posts")
23
+ * @param {string} resolvedName - Resolved (possibly scoped) record name (e.g. "security/rules")
24
+ * @param {string} [plainName] - Original unscoped record name (e.g. "rules"). Falls back to resolvedName.
23
25
  * @returns {Array} Merged array
24
26
  */
25
- function applyRecordOverrides(baseRecords, recordName) {
27
+ function applyRecordOverrides(baseRecords, resolvedName, plainName) {
26
28
  const allParams = isHideMode() ? getAllShadows() : getAllParams()
27
- const prefix = `record.${recordName}.`
29
+
30
+ // Check both the resolved (scoped) prefix and the plain (unscoped) prefix.
31
+ // Callers write overrides with the plain name, but the data index resolves
32
+ // to the scoped name — we need to match both so overrides are not silently
33
+ // dropped for prototype-scoped records.
34
+ const resolvedPrefix = `record.${resolvedName}.`
35
+ const plainPrefix = plainName && plainName !== resolvedName
36
+ ? `record.${plainName}.`
37
+ : null
28
38
 
29
39
  // Collect only the params that target this record
30
- const overrideKeys = Object.keys(allParams).filter(k => k.startsWith(prefix))
40
+ const overrideKeys = Object.keys(allParams).filter(k =>
41
+ k.startsWith(resolvedPrefix) || (plainPrefix && k.startsWith(plainPrefix))
42
+ )
31
43
  if (overrideKeys.length === 0) return baseRecords
32
44
 
33
45
  const records = deepClone(baseRecords)
@@ -36,6 +48,8 @@ function applyRecordOverrides(baseRecords, recordName) {
36
48
  // key format: record.{name}.{entryId}.{field...}
37
49
  const byEntryId = {}
38
50
  for (const key of overrideKeys) {
51
+ // Determine which prefix matched to slice correctly
52
+ const prefix = key.startsWith(resolvedPrefix) ? resolvedPrefix : plainPrefix
39
53
  const rest = key.slice(prefix.length) // "{entryId}.{field...}"
40
54
  const dotIdx = rest.indexOf('.')
41
55
  if (dotIdx === -1) continue // no field path — skip
@@ -91,6 +105,8 @@ function applyRecordOverrides(baseRecords, recordName) {
91
105
  export function useRecord(recordName, paramName = 'id') {
92
106
  const params = useParams()
93
107
  const paramValue = params[paramName]
108
+ const context = useContext(StoryboardContext)
109
+ const prototypeName = context?.prototypeName ?? null
94
110
 
95
111
  // Re-render on hash or localStorage changes so overrides are reactive
96
112
  const hashString = useSyncExternalStore(subscribeToHash, getHashSnapshot)
@@ -99,14 +115,15 @@ export function useRecord(recordName, paramName = 'id') {
99
115
  return useMemo(() => {
100
116
  if (!paramValue) return null
101
117
  try {
102
- const base = loadRecord(recordName)
103
- const merged = applyRecordOverrides(base, recordName)
118
+ const resolvedName = resolveRecordName(prototypeName, recordName)
119
+ const base = loadRecord(resolvedName)
120
+ const merged = applyRecordOverrides(base, resolvedName, recordName)
104
121
  return merged.find(e => e[paramName] === paramValue) ?? null
105
122
  } catch (err) {
106
123
  console.error(`[useRecord] ${err.message}`)
107
124
  return null
108
125
  }
109
- }, [recordName, paramName, paramValue, hashString, storageString]) // eslint-disable-line react-hooks/exhaustive-deps
126
+ }, [recordName, paramName, paramValue, prototypeName, hashString, storageString]) // eslint-disable-line react-hooks/exhaustive-deps
110
127
  }
111
128
 
112
129
  /**
@@ -121,17 +138,21 @@ export function useRecord(recordName, paramName = 'id') {
121
138
  * const allPosts = useRecords('posts')
122
139
  */
123
140
  export function useRecords(recordName) {
141
+ const context = useContext(StoryboardContext)
142
+ const prototypeName = context?.prototypeName ?? null
143
+
124
144
  // Re-render on hash or localStorage changes so overrides are reactive
125
145
  const hashString = useSyncExternalStore(subscribeToHash, getHashSnapshot)
126
146
  const storageString = useSyncExternalStore(subscribeToStorage, getStorageSnapshot)
127
147
 
128
148
  return useMemo(() => {
129
149
  try {
130
- const base = loadRecord(recordName)
131
- return applyRecordOverrides(base, recordName)
150
+ const resolvedName = resolveRecordName(prototypeName, recordName)
151
+ const base = loadRecord(resolvedName)
152
+ return applyRecordOverrides(base, resolvedName, recordName)
132
153
  } catch (err) {
133
154
  console.error(`[useRecords] ${err.message}`)
134
155
  return []
135
156
  }
136
- }, [recordName, hashString, storageString]) // eslint-disable-line react-hooks/exhaustive-deps
157
+ }, [recordName, prototypeName, hashString, storageString]) // eslint-disable-line react-hooks/exhaustive-deps
137
158
  }
@@ -1,6 +1,8 @@
1
+ import React from 'react'
1
2
  import { renderHook, act } from '@testing-library/react'
2
3
  import { seedTestData, TEST_RECORDS } from '../../test-utils.js'
3
- import { activateHideMode, setShadow } from '@dfosco/storyboard-core'
4
+ import { activateHideMode, setShadow, init } from '@dfosco/storyboard-core'
5
+ import { StoryboardContext } from '../StoryboardContext.js'
4
6
 
5
7
  vi.mock('react-router-dom', async () => {
6
8
  const actual = await vi.importActual('react-router-dom')
@@ -15,6 +17,20 @@ beforeEach(() => {
15
17
  useParams.mockReturnValue({})
16
18
  })
17
19
 
20
+ /**
21
+ * Create a wrapper that provides StoryboardContext with a prototypeName,
22
+ * used for testing scoped (prototype-level) records.
23
+ */
24
+ function createPrototypeWrapper(prototypeName) {
25
+ return function Wrapper({ children }) {
26
+ return React.createElement(
27
+ StoryboardContext.Provider,
28
+ { value: { data: {}, prototypeName } },
29
+ children,
30
+ )
31
+ }
32
+ }
33
+
18
34
  // ── useRecord ──
19
35
 
20
36
  describe('useRecord', () => {
@@ -128,3 +144,78 @@ describe('useRecords (hide mode)', () => {
128
144
  expect(newPost.title).toBe('New Shadow')
129
145
  })
130
146
  })
147
+
148
+ // ── Scoped (prototype) records ──
149
+
150
+ const SCOPED_RECORDS = {
151
+ 'security/rules': [
152
+ { id: 'constant-condition', title: 'Constant Condition', state: 'open' },
153
+ { id: 'unused-var', title: 'Unused Variable', state: 'open' },
154
+ ],
155
+ }
156
+
157
+ function seedScopedData() {
158
+ init({
159
+ flows: {},
160
+ objects: {},
161
+ records: SCOPED_RECORDS,
162
+ })
163
+ }
164
+
165
+ describe('useRecords (scoped records)', () => {
166
+ beforeEach(() => {
167
+ seedScopedData()
168
+ window.location.hash = ''
169
+ })
170
+
171
+ it('applies overrides written with the plain (unscoped) record name', () => {
172
+ // Callers write: record.rules.constant-condition.state=dismissed
173
+ // Reader resolves to "security/rules" — this was the bug
174
+ window.location.hash = 'record.rules.constant-condition.state=dismissed'
175
+
176
+ const wrapper = createPrototypeWrapper('security')
177
+ const { result } = renderHook(() => useRecords('rules'), { wrapper })
178
+
179
+ const rule = result.current.find(e => e.id === 'constant-condition')
180
+ expect(rule.state).toBe('dismissed')
181
+ })
182
+
183
+ it('applies overrides written with the resolved (scoped) record name', () => {
184
+ window.location.hash = 'record.security/rules.constant-condition.state=dismissed'
185
+
186
+ const wrapper = createPrototypeWrapper('security')
187
+ const { result } = renderHook(() => useRecords('rules'), { wrapper })
188
+
189
+ const rule = result.current.find(e => e.id === 'constant-condition')
190
+ expect(rule.state).toBe('dismissed')
191
+ })
192
+
193
+ it('merges overrides from both plain and scoped prefixes', () => {
194
+ window.location.hash =
195
+ 'record.rules.constant-condition.state=dismissed' +
196
+ '&record.security/rules.unused-var.state=resolved'
197
+
198
+ const wrapper = createPrototypeWrapper('security')
199
+ const { result } = renderHook(() => useRecords('rules'), { wrapper })
200
+
201
+ expect(result.current.find(e => e.id === 'constant-condition').state).toBe('dismissed')
202
+ expect(result.current.find(e => e.id === 'unused-var').state).toBe('resolved')
203
+ })
204
+ })
205
+
206
+ describe('useRecord (scoped records)', () => {
207
+ beforeEach(() => {
208
+ seedScopedData()
209
+ window.location.hash = ''
210
+ useParams.mockReturnValue({ id: 'constant-condition' })
211
+ })
212
+
213
+ it('applies overrides written with the plain (unscoped) record name', () => {
214
+ window.location.hash = 'record.rules.constant-condition.state=dismissed'
215
+
216
+ const wrapper = createPrototypeWrapper('security')
217
+ const { result } = renderHook(() => useRecord('rules'), { wrapper })
218
+
219
+ expect(result.current.state).toBe('dismissed')
220
+ })
221
+ })
@@ -2,27 +2,37 @@ import { useContext, useCallback } from 'react'
2
2
  import { StoryboardContext } from '../StoryboardContext.js'
3
3
 
4
4
  /**
5
- * Read the current scene name and programmatically switch scenes.
5
+ * Read the current flow name and programmatically switch flows.
6
6
  *
7
- * @returns {{ sceneName: string, switchScene: (name: string) => void }}
8
- * - sceneName – current active scene (e.g. "default")
9
- * - switchScene – navigate to a different scene by updating ?scene= param
7
+ * @returns {{ flowName: string, switchFlow: (name: string) => void }}
8
+ * - flowName – current active flow (e.g. "default")
9
+ * - switchFlow – navigate to a different flow by updating ?flow= param
10
10
  */
11
- export function useScene() {
11
+ export function useFlow() {
12
12
  const context = useContext(StoryboardContext)
13
13
  if (context === null) {
14
- throw new Error('useScene must be used within a <StoryboardProvider>')
14
+ throw new Error('useFlow must be used within a <StoryboardProvider>')
15
15
  }
16
16
 
17
- const switchScene = useCallback((name) => {
17
+ const switchFlow = useCallback((name) => {
18
18
  const url = new URL(window.location.href)
19
- url.searchParams.set('scene', name)
20
- // Preserve hash params across scene switches
19
+ url.searchParams.delete('scene')
20
+ url.searchParams.set('flow', name)
21
+ // Preserve hash params across flow switches
21
22
  window.location.href = url.toString()
22
23
  }, [])
23
24
 
24
25
  return {
25
- sceneName: context.sceneName,
26
- switchScene,
26
+ flowName: context.flowName,
27
+ switchFlow,
28
+ }
29
+ }
30
+
31
+ /** @deprecated Use useFlow() */
32
+ export function useScene() {
33
+ const { flowName, switchFlow } = useFlow()
34
+ return {
35
+ sceneName: flowName,
36
+ switchScene: switchFlow,
27
37
  }
28
38
  }
@@ -1,39 +1,66 @@
1
1
  import { renderHook } from '@testing-library/react'
2
+ import { useFlow } from './useScene.js'
2
3
  import { useScene } from './useScene.js'
3
- import { seedTestData, createWrapper, TEST_SCENES } from '../test-utils.js'
4
+ import { seedTestData, createWrapper, TEST_FLOWS } from '../test-utils.js'
4
5
 
5
- const sceneData = TEST_SCENES.default
6
+ const flowData = TEST_FLOWS.default
6
7
 
7
8
  beforeEach(() => {
8
9
  seedTestData()
9
10
  })
10
11
 
11
- describe('useScene', () => {
12
+ describe('useFlow', () => {
13
+ it('returns { flowName, switchFlow }', () => {
14
+ const { result } = renderHook(() => useFlow(), {
15
+ wrapper: createWrapper(flowData),
16
+ })
17
+ expect(result.current).toHaveProperty('flowName')
18
+ expect(result.current).toHaveProperty('switchFlow')
19
+ })
20
+
21
+ it('flowName matches the value from context', () => {
22
+ const { result } = renderHook(() => useFlow(), {
23
+ wrapper: createWrapper(flowData, 'other'),
24
+ })
25
+ expect(result.current.flowName).toBe('other')
26
+ })
27
+
28
+ it('switchFlow is a function', () => {
29
+ const { result } = renderHook(() => useFlow(), {
30
+ wrapper: createWrapper(flowData),
31
+ })
32
+ expect(typeof result.current.switchFlow).toBe('function')
33
+ })
34
+
35
+ it('throws when used outside StoryboardProvider', () => {
36
+ expect(() => {
37
+ renderHook(() => useFlow())
38
+ }).toThrow('useFlow must be used within a <StoryboardProvider>')
39
+ })
40
+ })
41
+
42
+ // ── useScene (deprecated alias) ──
43
+
44
+ describe('useScene (deprecated alias)', () => {
12
45
  it('returns { sceneName, switchScene }', () => {
13
46
  const { result } = renderHook(() => useScene(), {
14
- wrapper: createWrapper(sceneData),
47
+ wrapper: createWrapper(flowData),
15
48
  })
16
49
  expect(result.current).toHaveProperty('sceneName')
17
50
  expect(result.current).toHaveProperty('switchScene')
18
51
  })
19
52
 
20
- it('sceneName matches the value from context', () => {
53
+ it('sceneName matches the flow name from context', () => {
21
54
  const { result } = renderHook(() => useScene(), {
22
- wrapper: createWrapper(sceneData, 'other'),
55
+ wrapper: createWrapper(flowData, 'other'),
23
56
  })
24
57
  expect(result.current.sceneName).toBe('other')
25
58
  })
26
59
 
27
60
  it('switchScene is a function', () => {
28
61
  const { result } = renderHook(() => useScene(), {
29
- wrapper: createWrapper(sceneData),
62
+ wrapper: createWrapper(flowData),
30
63
  })
31
64
  expect(typeof result.current.switchScene).toBe('function')
32
65
  })
33
-
34
- it('throws when used outside StoryboardProvider', () => {
35
- expect(() => {
36
- renderHook(() => useScene())
37
- }).toThrow('useScene must be used within a <StoryboardProvider>')
38
- })
39
66
  })
@@ -7,24 +7,24 @@ import { isHideMode, getShadow, getAllShadows } from '@dfosco/storyboard-core'
7
7
  import { subscribeToStorage, getStorageSnapshot } from '@dfosco/storyboard-core'
8
8
 
9
9
  /**
10
- * Access scene data by dot-notation path.
11
- * Hash params override scene data — both exact matches and nested paths.
10
+ * Access flow data by dot-notation path.
11
+ * Hash params override flow data — both exact matches and nested paths.
12
12
  *
13
13
  * Examples:
14
- * useSceneData('user.name') with #user.name=Alice → "Alice"
15
- * useSceneData('repositories') with #repositories.0.name=Foo
14
+ * useFlowData('user.name') with #user.name=Alice → "Alice"
15
+ * useFlowData('repositories') with #repositories.0.name=Foo
16
16
  * → deep clone of repositories array with [0].name overridden to "Foo"
17
17
  *
18
18
  * @param {string} [path] - Dot-notation path (e.g. 'user.profile.name').
19
- * Omit to get the entire scene object.
19
+ * Omit to get the entire flow object.
20
20
  * @returns {*} The resolved value. Returns {} if path is missing after loading.
21
21
  * @throws If used outside a StoryboardProvider.
22
22
  */
23
- export function useSceneData(path) {
23
+ export function useFlowData(path) {
24
24
  const context = useContext(StoryboardContext)
25
25
 
26
26
  if (context === null) {
27
- throw new Error('useSceneData must be used within a <StoryboardProvider>')
27
+ throw new Error('useFlowData must be used within a <StoryboardProvider>')
28
28
  }
29
29
 
30
30
  const { data, loading, error } = context
@@ -73,7 +73,7 @@ export function useSceneData(path) {
73
73
  }
74
74
 
75
75
  if (sceneValue === undefined) {
76
- console.warn(`[useSceneData] Path "${path}" not found in scene data.`)
76
+ console.warn(`[useFlowData] Path "${path}" not found in flow data.`)
77
77
  return {}
78
78
  }
79
79
 
@@ -83,15 +83,21 @@ export function useSceneData(path) {
83
83
  return result
84
84
  }
85
85
 
86
+ /** @deprecated Use useFlowData() */
87
+ export const useSceneData = useFlowData
88
+
86
89
  /**
87
- * Returns true while scene data is still loading.
90
+ * Returns true while flow data is still loading.
88
91
  */
89
- export function useSceneLoading() {
92
+ export function useFlowLoading() {
90
93
  const context = useContext(StoryboardContext)
91
94
 
92
95
  if (context === null) {
93
- throw new Error('useSceneLoading must be used within a <StoryboardProvider>')
96
+ throw new Error('useFlowLoading must be used within a <StoryboardProvider>')
94
97
  }
95
98
 
96
99
  return context.loading
97
100
  }
101
+
102
+ /** @deprecated Use useFlowLoading() */
103
+ export const useSceneLoading = useFlowLoading
@@ -1,38 +1,38 @@
1
1
  import { renderHook } from '@testing-library/react'
2
- import { useSceneData, useSceneLoading } from './useSceneData.js'
3
- import { seedTestData, createWrapper, TEST_SCENES } from '../test-utils.js'
2
+ import { useFlowData, useFlowLoading, useSceneData, useSceneLoading } from './useSceneData.js'
3
+ import { seedTestData, createWrapper, TEST_FLOWS } from '../test-utils.js'
4
4
 
5
- const sceneData = TEST_SCENES.default
5
+ const flowData = TEST_FLOWS.default
6
6
 
7
7
  beforeEach(() => {
8
8
  seedTestData()
9
9
  })
10
10
 
11
- describe('useSceneData', () => {
12
- it('returns entire scene object when no path given', () => {
13
- const { result } = renderHook(() => useSceneData(), {
14
- wrapper: createWrapper(sceneData),
11
+ describe('useFlowData', () => {
12
+ it('returns entire flow object when no path given', () => {
13
+ const { result } = renderHook(() => useFlowData(), {
14
+ wrapper: createWrapper(flowData),
15
15
  })
16
- expect(result.current).toEqual(sceneData)
16
+ expect(result.current).toEqual(flowData)
17
17
  })
18
18
 
19
19
  it('returns nested value by dot-notation path', () => {
20
- const { result } = renderHook(() => useSceneData('user.name'), {
21
- wrapper: createWrapper(sceneData),
20
+ const { result } = renderHook(() => useFlowData('user.name'), {
21
+ wrapper: createWrapper(flowData),
22
22
  })
23
23
  expect(result.current).toBe('Jane')
24
24
  })
25
25
 
26
26
  it('returns deep nested value', () => {
27
- const { result } = renderHook(() => useSceneData('user.profile.bio'), {
28
- wrapper: createWrapper(sceneData),
27
+ const { result } = renderHook(() => useFlowData('user.profile.bio'), {
28
+ wrapper: createWrapper(flowData),
29
29
  })
30
30
  expect(result.current).toBe('Dev')
31
31
  })
32
32
 
33
33
  it('returns array by path', () => {
34
- const { result } = renderHook(() => useSceneData('projects'), {
35
- wrapper: createWrapper(sceneData),
34
+ const { result } = renderHook(() => useFlowData('projects'), {
35
+ wrapper: createWrapper(flowData),
36
36
  })
37
37
  expect(result.current).toEqual([
38
38
  { id: 1, name: 'alpha' },
@@ -41,16 +41,16 @@ describe('useSceneData', () => {
41
41
  })
42
42
 
43
43
  it('returns array element by index path', () => {
44
- const { result } = renderHook(() => useSceneData('projects.0'), {
45
- wrapper: createWrapper(sceneData),
44
+ const { result } = renderHook(() => useFlowData('projects.0'), {
45
+ wrapper: createWrapper(flowData),
46
46
  })
47
47
  expect(result.current).toEqual({ id: 1, name: 'alpha' })
48
48
  })
49
49
 
50
50
  it('returns empty object and warns for missing path', () => {
51
51
  const spy = vi.spyOn(console, 'warn').mockImplementation(() => {})
52
- const { result } = renderHook(() => useSceneData('nonexistent.path'), {
53
- wrapper: createWrapper(sceneData),
52
+ const { result } = renderHook(() => useFlowData('nonexistent.path'), {
53
+ wrapper: createWrapper(flowData),
54
54
  })
55
55
  expect(result.current).toEqual({})
56
56
  expect(spy).toHaveBeenCalledWith(
@@ -61,48 +61,76 @@ describe('useSceneData', () => {
61
61
 
62
62
  it('throws when used outside StoryboardProvider', () => {
63
63
  expect(() => {
64
- renderHook(() => useSceneData())
65
- }).toThrow('useSceneData must be used within a <StoryboardProvider>')
64
+ renderHook(() => useFlowData())
65
+ }).toThrow('useFlowData must be used within a <StoryboardProvider>')
66
66
  })
67
67
 
68
68
  it('returns hash override value when param matches path', () => {
69
69
  window.location.hash = '#user.name=Alice'
70
- const { result } = renderHook(() => useSceneData('user.name'), {
71
- wrapper: createWrapper(sceneData),
70
+ const { result } = renderHook(() => useFlowData('user.name'), {
71
+ wrapper: createWrapper(flowData),
72
72
  })
73
73
  expect(result.current).toBe('Alice')
74
74
  })
75
75
 
76
76
  it('applies child overrides to arrays', () => {
77
77
  window.location.hash = '#projects.0.name=gamma'
78
- const { result } = renderHook(() => useSceneData('projects'), {
79
- wrapper: createWrapper(sceneData),
78
+ const { result } = renderHook(() => useFlowData('projects'), {
79
+ wrapper: createWrapper(flowData),
80
80
  })
81
81
  expect(result.current[0].name).toBe('gamma')
82
82
  expect(result.current[1].name).toBe('beta')
83
83
  })
84
84
 
85
- it('returns full scene with all overrides applied when no path', () => {
85
+ it('returns full flow with all overrides applied when no path', () => {
86
86
  window.location.hash = '#user.name=Alice'
87
- const { result } = renderHook(() => useSceneData(), {
88
- wrapper: createWrapper(sceneData),
87
+ const { result } = renderHook(() => useFlowData(), {
88
+ wrapper: createWrapper(flowData),
89
89
  })
90
90
  expect(result.current.user.name).toBe('Alice')
91
91
  expect(result.current.settings.theme).toBe('dark')
92
92
  })
93
93
  })
94
94
 
95
- describe('useSceneLoading', () => {
95
+ describe('useFlowLoading', () => {
96
96
  it('returns false when not loading', () => {
97
- const { result } = renderHook(() => useSceneLoading(), {
98
- wrapper: createWrapper(sceneData),
97
+ const { result } = renderHook(() => useFlowLoading(), {
98
+ wrapper: createWrapper(flowData),
99
99
  })
100
100
  expect(result.current).toBe(false)
101
101
  })
102
102
 
103
103
  it('throws when used outside StoryboardProvider', () => {
104
104
  expect(() => {
105
- renderHook(() => useSceneLoading())
106
- }).toThrow('useSceneLoading must be used within a <StoryboardProvider>')
105
+ renderHook(() => useFlowLoading())
106
+ }).toThrow('useFlowLoading must be used within a <StoryboardProvider>')
107
+ })
108
+ })
109
+
110
+ // ── Deprecated aliases ──
111
+
112
+ describe('useSceneData (deprecated alias)', () => {
113
+ it('is the same function as useFlowData', () => {
114
+ expect(useSceneData).toBe(useFlowData)
115
+ })
116
+
117
+ it('returns flow data', () => {
118
+ const { result } = renderHook(() => useSceneData(), {
119
+ wrapper: createWrapper(flowData),
120
+ })
121
+ expect(result.current).toEqual(flowData)
122
+ })
123
+ })
124
+
125
+ describe('useSceneLoading (deprecated alias)', () => {
126
+ it('is the same function as useFlowLoading', () => {
127
+ expect(useSceneLoading).toBe(useFlowLoading)
128
+ })
129
+
130
+ it('returns loading state', () => {
131
+ const { result } = renderHook(() => useSceneLoading(), {
132
+ wrapper: createWrapper(flowData),
133
+ })
134
+ expect(result.current).toBe(false)
107
135
  })
108
136
  })
package/src/index.js CHANGED
@@ -10,10 +10,12 @@ export { default as StoryboardProvider } from './context.jsx'
10
10
  export { StoryboardContext } from './StoryboardContext.js'
11
11
 
12
12
  // Hooks
13
+ export { useFlowData, useFlowLoading } from './hooks/useSceneData.js'
14
+ // Deprecated aliases
13
15
  export { useSceneData, useSceneLoading } from './hooks/useSceneData.js'
14
16
  export { useOverride } from './hooks/useOverride.js'
15
17
  export { useOverride as useSession } from './hooks/useOverride.js' // deprecated alias
16
- export { useScene } from './hooks/useScene.js'
18
+ export { useFlow, useScene } from './hooks/useScene.js'
17
19
  export { useRecord, useRecords } from './hooks/useRecord.js'
18
20
  export { useObject } from './hooks/useObject.js'
19
21
  export { useLocalStorage } from './hooks/useLocalStorage.js'
package/src/test-utils.js CHANGED
@@ -3,7 +3,7 @@ import { StoryboardContext } from './StoryboardContext.js'
3
3
  import { init } from '@dfosco/storyboard-core'
4
4
 
5
5
  // Default test data
6
- export const TEST_SCENES = {
6
+ export const TEST_FLOWS = {
7
7
  default: {
8
8
  user: { name: 'Jane', profile: { bio: 'Dev' } },
9
9
  settings: { theme: 'dark' },
@@ -15,6 +15,9 @@ export const TEST_SCENES = {
15
15
  },
16
16
  }
17
17
 
18
+ /** @deprecated Use TEST_FLOWS */
19
+ export const TEST_SCENES = TEST_FLOWS
20
+
18
21
  export const TEST_OBJECTS = {
19
22
  'jane-doe': { name: 'Jane Doe', role: 'admin' },
20
23
  }
@@ -27,15 +30,15 @@ export const TEST_RECORDS = {
27
30
  }
28
31
 
29
32
  export function seedTestData() {
30
- init({ scenes: TEST_SCENES, objects: TEST_OBJECTS, records: TEST_RECORDS })
33
+ init({ flows: TEST_FLOWS, objects: TEST_OBJECTS, records: TEST_RECORDS })
31
34
  }
32
35
 
33
- // Wrapper that provides StoryboardContext with given scene data
34
- export function createWrapper(sceneData, sceneName = 'default') {
36
+ // Wrapper that provides StoryboardContext with given flow data
37
+ export function createWrapper(flowData, flowName = 'default') {
35
38
  return function Wrapper({ children }) {
36
39
  return createElement(
37
40
  StoryboardContext.Provider,
38
- { value: { data: sceneData, error: null, loading: false, sceneName } },
41
+ { value: { data: flowData, error: null, loading: false, flowName, sceneName: flowName } },
39
42
  children
40
43
  )
41
44
  }