@dfosco/storyboard-react 1.1.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.
@@ -0,0 +1,80 @@
1
+ import { useCallback, useContext, useSyncExternalStore } from 'react'
2
+ import { StoryboardContext } from '../StoryboardContext.js'
3
+ import { getByPath } from '@dfosco/storyboard-core'
4
+ import { getParam, setParam, removeParam } from '@dfosco/storyboard-core'
5
+ import { subscribeToHash } from '@dfosco/storyboard-core'
6
+ import { isHideMode, getShadow, setShadow, removeShadow } from '@dfosco/storyboard-core'
7
+ import { subscribeToStorage, getStorageSnapshot } from '@dfosco/storyboard-core'
8
+
9
+ /**
10
+ * Read/write overrides on top of scene data.
11
+ *
12
+ * **Normal mode:**
13
+ * Read priority: URL hash param → Scene JSON value → undefined
14
+ * Write target: URL hash + shadow copy to localStorage
15
+ *
16
+ * **Hide mode** (activated by `?hide`):
17
+ * Read priority: shadow localStorage → Scene JSON value → undefined
18
+ * Write target: shadow localStorage only (URL stays clean)
19
+ *
20
+ * Every write also mirrors to localStorage shadow keys, so hide mode
21
+ * can hot-swap without data loss.
22
+ *
23
+ * @param {string} path - Dot-notation key (e.g. 'settings.theme')
24
+ * @returns {[any, function, function]}
25
+ * [0] current value (override ?? scene default)
26
+ * [1] setValue(newValue) – write an override
27
+ * [2] clearValue() – remove the override, reverting to scene default
28
+ */
29
+ export function useOverride(path) {
30
+ const context = useContext(StoryboardContext)
31
+ if (context === null) {
32
+ throw new Error('useOverride must be used within a <StoryboardProvider>')
33
+ }
34
+
35
+ const { data } = context
36
+ const hidden = isHideMode()
37
+
38
+ // Scene default for this path (fallback when no override exists)
39
+ const sceneDefault = data != null ? getByPath(data, path) : undefined
40
+
41
+ // Subscribe to both sources for reactivity
42
+ const getHashSnap = useCallback(() => getParam(path), [path])
43
+ const hashValue = useSyncExternalStore(subscribeToHash, getHashSnap)
44
+ useSyncExternalStore(subscribeToStorage, getStorageSnapshot)
45
+
46
+ // Resolved value depends on mode
47
+ let value
48
+ if (hidden) {
49
+ const shadowValue = getShadow(path)
50
+ value = shadowValue !== null ? shadowValue : sceneDefault
51
+ } else {
52
+ value = hashValue !== null ? hashValue : sceneDefault
53
+ }
54
+
55
+ /** Write a value — targets hash or shadow depending on mode */
56
+ const setValue = useCallback(
57
+ (newValue) => {
58
+ if (isHideMode()) {
59
+ setShadow(path, newValue)
60
+ } else {
61
+ setParam(path, newValue)
62
+ // Always mirror to shadow so hide mode can hot-swap
63
+ setShadow(path, newValue)
64
+ }
65
+ },
66
+ [path],
67
+ )
68
+
69
+ /** Remove the override, reverting to scene default */
70
+ const clearValue = useCallback(() => {
71
+ if (isHideMode()) {
72
+ removeShadow(path)
73
+ } else {
74
+ removeParam(path)
75
+ removeShadow(path)
76
+ }
77
+ }, [path])
78
+
79
+ return [value, setValue, clearValue]
80
+ }
@@ -0,0 +1,66 @@
1
+ import { renderHook, act } from '@testing-library/react'
2
+ import { useOverride } from './useOverride.js'
3
+ import { seedTestData, createWrapper, TEST_SCENES } from '../test-utils.js'
4
+
5
+ const sceneData = TEST_SCENES.default
6
+
7
+ beforeEach(() => {
8
+ seedTestData()
9
+ })
10
+
11
+ describe('useOverride', () => {
12
+ it('returns [value, setValue, clearValue] tuple', () => {
13
+ const { result } = renderHook(() => useOverride('settings.theme'), {
14
+ wrapper: createWrapper(sceneData),
15
+ })
16
+ expect(result.current).toHaveLength(3)
17
+ expect(typeof result.current[1]).toBe('function')
18
+ expect(typeof result.current[2]).toBe('function')
19
+ })
20
+
21
+ it('value falls back to scene default when no hash override', () => {
22
+ const { result } = renderHook(() => useOverride('settings.theme'), {
23
+ wrapper: createWrapper(sceneData),
24
+ })
25
+ expect(result.current[0]).toBe('dark')
26
+ })
27
+
28
+ it('value reads from hash override when present', () => {
29
+ window.location.hash = '#settings.theme=light'
30
+ const { result } = renderHook(() => useOverride('settings.theme'), {
31
+ wrapper: createWrapper(sceneData),
32
+ })
33
+ expect(result.current[0]).toBe('light')
34
+ })
35
+
36
+ it('setValue writes to hash', () => {
37
+ const { result } = renderHook(() => useOverride('settings.theme'), {
38
+ wrapper: createWrapper(sceneData),
39
+ })
40
+
41
+ act(() => {
42
+ result.current[1]('blue')
43
+ })
44
+
45
+ expect(window.location.hash).toContain('settings.theme=blue')
46
+ })
47
+
48
+ it('clearValue removes hash param', () => {
49
+ window.location.hash = '#settings.theme=red'
50
+ const { result } = renderHook(() => useOverride('settings.theme'), {
51
+ wrapper: createWrapper(sceneData),
52
+ })
53
+
54
+ act(() => {
55
+ result.current[2]()
56
+ })
57
+
58
+ expect(window.location.hash).not.toContain('settings.theme')
59
+ })
60
+
61
+ it('throws when used outside StoryboardProvider', () => {
62
+ expect(() => {
63
+ renderHook(() => useOverride('settings.theme'))
64
+ }).toThrow('useOverride must be used within a <StoryboardProvider>')
65
+ })
66
+ })
@@ -0,0 +1,130 @@
1
+ import { useMemo, useSyncExternalStore } from 'react'
2
+ import { useParams } from 'react-router-dom'
3
+ import { loadRecord } from '@dfosco/storyboard-core'
4
+ import { deepClone, setByPath } from '@dfosco/storyboard-core'
5
+ import { getAllParams } from '@dfosco/storyboard-core'
6
+ import { subscribeToHash, getHashSnapshot } from '@dfosco/storyboard-core'
7
+
8
+ /**
9
+ * Collect hash overrides for a record and merge them into the base array.
10
+ *
11
+ * Hash convention: record.{recordName}.{entryId}.{field}=value
12
+ *
13
+ * - Existing entries (matched by id) get fields merged on top.
14
+ * - Unknown ids create new entries appended to the array.
15
+ *
16
+ * @param {Array} baseRecords - The original record array (will be deep-cloned)
17
+ * @param {string} recordName - Record collection name (e.g. "posts")
18
+ * @returns {Array} Merged array
19
+ */
20
+ function applyRecordOverrides(baseRecords, recordName) {
21
+ const allParams = getAllParams()
22
+ const prefix = `record.${recordName}.`
23
+
24
+ // Collect only the params that target this record
25
+ const overrideKeys = Object.keys(allParams).filter(k => k.startsWith(prefix))
26
+ if (overrideKeys.length === 0) return baseRecords
27
+
28
+ const records = deepClone(baseRecords)
29
+
30
+ // Group overrides by entry id
31
+ // key format: record.{name}.{entryId}.{field...}
32
+ const byEntryId = {}
33
+ for (const key of overrideKeys) {
34
+ const rest = key.slice(prefix.length) // "{entryId}.{field...}"
35
+ const dotIdx = rest.indexOf('.')
36
+ if (dotIdx === -1) continue // no field path — skip
37
+ const entryId = rest.slice(0, dotIdx)
38
+ const fieldPath = rest.slice(dotIdx + 1)
39
+ if (!byEntryId[entryId]) byEntryId[entryId] = {}
40
+ byEntryId[entryId][fieldPath] = allParams[key]
41
+ }
42
+
43
+ for (const [entryId, fields] of Object.entries(byEntryId)) {
44
+ const existing = records.find(e => e.id === entryId)
45
+ if (existing) {
46
+ // Merge fields into existing entry
47
+ for (const [fieldPath, value] of Object.entries(fields)) {
48
+ setByPath(existing, fieldPath, value)
49
+ }
50
+ } else {
51
+ // Create new entry and append
52
+ const newEntry = { id: entryId }
53
+ for (const [fieldPath, value] of Object.entries(fields)) {
54
+ setByPath(newEntry, fieldPath, value)
55
+ }
56
+ records.push(newEntry)
57
+ }
58
+ }
59
+
60
+ return records
61
+ }
62
+
63
+ /**
64
+ * Loads a single record entry from a record collection, matched by URL param.
65
+ * Hash overrides are applied before lookup — both field overrides on existing
66
+ * entries and entirely new entries added via the URL are supported.
67
+ *
68
+ * The `paramName` serves double duty: it's both the route param to read from
69
+ * the URL and the record field to match against. This maps naturally to the
70
+ * file-based routing convention — `[id].jsx` matches entry.id,
71
+ * `[permalink].jsx` would match entry.permalink, etc.
72
+ *
73
+ * @param {string} recordName - Name of the record file (e.g., "posts")
74
+ * @param {string} paramName - Route param name, also used as the entry field to match
75
+ * @returns {object|null} The matched record entry, or null if not found
76
+ *
77
+ * @example
78
+ * // In pages/issues/[id].jsx:
79
+ * const issue = useRecord('issues', 'id')
80
+ * // URL /issues/refactor-auth-sso → finds entry where entry.id === 'refactor-auth-sso'
81
+ *
82
+ * // In pages/posts/[permalink].jsx:
83
+ * const post = useRecord('posts', 'permalink')
84
+ * // URL /posts/hello-world → finds entry where entry.permalink === 'hello-world'
85
+ */
86
+ export function useRecord(recordName, paramName = 'id') {
87
+ const params = useParams()
88
+ const paramValue = params[paramName]
89
+
90
+ // Re-render on hash changes so overrides are reactive
91
+ const hashString = useSyncExternalStore(subscribeToHash, getHashSnapshot)
92
+
93
+ return useMemo(() => {
94
+ if (!paramValue) return null
95
+ try {
96
+ const base = loadRecord(recordName)
97
+ const merged = applyRecordOverrides(base, recordName)
98
+ return merged.find(e => e[paramName] === paramValue) ?? null
99
+ } catch (err) {
100
+ console.error(`[useRecord] ${err.message}`)
101
+ return null
102
+ }
103
+ }, [recordName, paramName, paramValue, hashString]) // eslint-disable-line react-hooks/exhaustive-deps
104
+ }
105
+
106
+ /**
107
+ * Loads all entries from a record collection.
108
+ * Hash overrides are applied — existing entries can be modified and
109
+ * new entries can be created entirely from URL hash params.
110
+ *
111
+ * @param {string} recordName - Name of the record file (e.g., "posts")
112
+ * @returns {Array} All record entries (with overrides applied)
113
+ *
114
+ * @example
115
+ * const allPosts = useRecords('posts')
116
+ */
117
+ export function useRecords(recordName) {
118
+ // Re-render on hash changes so overrides are reactive
119
+ const hashString = useSyncExternalStore(subscribeToHash, getHashSnapshot)
120
+
121
+ return useMemo(() => {
122
+ try {
123
+ const base = loadRecord(recordName)
124
+ return applyRecordOverrides(base, recordName)
125
+ } catch (err) {
126
+ console.error(`[useRecords] ${err.message}`)
127
+ return []
128
+ }
129
+ }, [recordName, hashString]) // eslint-disable-line react-hooks/exhaustive-deps
130
+ }
@@ -0,0 +1,81 @@
1
+ import { renderHook, act } from '@testing-library/react'
2
+ import { seedTestData, TEST_RECORDS } from '../../test-utils.js'
3
+
4
+ vi.mock('react-router-dom', async () => {
5
+ const actual = await vi.importActual('react-router-dom')
6
+ return { ...actual, useParams: vi.fn(() => ({})) }
7
+ })
8
+ import { useParams } from 'react-router-dom'
9
+
10
+ import { useRecord, useRecords } from './useRecord.js'
11
+
12
+ beforeEach(() => {
13
+ seedTestData()
14
+ useParams.mockReturnValue({})
15
+ })
16
+
17
+ // ── useRecord ──
18
+
19
+ describe('useRecord', () => {
20
+ it('returns null when no URL param matches', () => {
21
+ const { result } = renderHook(() => useRecord('posts'))
22
+ expect(result.current).toBeNull()
23
+ })
24
+
25
+ it('returns matching record entry when param is set', () => {
26
+ useParams.mockReturnValue({ id: 'post-1' })
27
+ const { result } = renderHook(() => useRecord('posts'))
28
+ expect(result.current).toEqual(TEST_RECORDS.posts[0])
29
+ })
30
+
31
+ it('returns null when param value does not match any entry', () => {
32
+ useParams.mockReturnValue({ id: 'nonexistent' })
33
+ const { result } = renderHook(() => useRecord('posts'))
34
+ expect(result.current).toBeNull()
35
+ })
36
+
37
+ it('defaults paramName to id', () => {
38
+ useParams.mockReturnValue({ id: 'post-2' })
39
+ const { result } = renderHook(() => useRecord('posts'))
40
+ expect(result.current).toEqual(TEST_RECORDS.posts[1])
41
+ })
42
+
43
+ it('returns null gracefully when record collection does not exist', () => {
44
+ useParams.mockReturnValue({ id: 'post-1' })
45
+ vi.spyOn(console, 'error').mockImplementation(() => {})
46
+ const { result } = renderHook(() => useRecord('nonexistent'))
47
+ expect(result.current).toBeNull()
48
+ console.error.mockRestore()
49
+ })
50
+ })
51
+
52
+ // ── useRecords ──
53
+
54
+ describe('useRecords', () => {
55
+ it('returns all entries from a record collection', () => {
56
+ const { result } = renderHook(() => useRecords('posts'))
57
+ expect(result.current).toEqual(TEST_RECORDS.posts)
58
+ })
59
+
60
+ it('returns empty array when record does not exist', () => {
61
+ vi.spyOn(console, 'error').mockImplementation(() => {})
62
+ const { result } = renderHook(() => useRecords('nonexistent'))
63
+ expect(result.current).toEqual([])
64
+ console.error.mockRestore()
65
+ })
66
+
67
+ it('applies hash overrides to existing entries', () => {
68
+ window.location.hash = 'record.posts.post-1.title=Updated'
69
+ const { result } = renderHook(() => useRecords('posts'))
70
+ const post1 = result.current.find(e => e.id === 'post-1')
71
+ expect(post1.title).toBe('Updated')
72
+ })
73
+
74
+ it('creates new entries from hash overrides', () => {
75
+ window.location.hash = 'record.posts.new-post.title=New'
76
+ const { result } = renderHook(() => useRecords('posts'))
77
+ const newPost = result.current.find(e => e.id === 'new-post')
78
+ expect(newPost).toBeTruthy()
79
+ expect(newPost.title).toBe('New')
80
+ })
81
+ })
@@ -0,0 +1,22 @@
1
+ import { useOverride } from './useOverride.js'
2
+
3
+ /**
4
+ * Read/write hash-param overrides for a specific record entry field.
5
+ *
6
+ * Builds the full override path as `record.{recordName}.{entryId}.{field}`
7
+ * and delegates to `useOverride`.
8
+ *
9
+ * @param {string} recordName - Record collection name (e.g. "posts")
10
+ * @param {string} entryId - The id of the record entry to override
11
+ * @param {string} field - Dot-notation field path within the entry (e.g. "title" or "author.name")
12
+ * @returns {[any, function, function]}
13
+ * [0] current value (override ?? record default)
14
+ * [1] setValue(newValue) – write an override to the URL hash
15
+ * [2] clearValue() – remove the override
16
+ *
17
+ * @example
18
+ * const [title, setTitle, clearTitle] = useRecordOverride('posts', 'welcome-to-storyboard', 'title')
19
+ */
20
+ export function useRecordOverride(recordName, entryId, field) {
21
+ return useOverride(`record.${recordName}.${entryId}.${field}`)
22
+ }
@@ -0,0 +1,52 @@
1
+ import { renderHook, act } from '@testing-library/react'
2
+ import { seedTestData, createWrapper, TEST_SCENES } from '../../test-utils.js'
3
+ import { useRecordOverride } from './useRecordOverride.js'
4
+
5
+ beforeEach(() => {
6
+ seedTestData()
7
+ })
8
+
9
+ const wrapper = createWrapper(TEST_SCENES.default)
10
+
11
+ describe('useRecordOverride', () => {
12
+ it('returns [value, setValue, clearValue]', () => {
13
+ const { result } = renderHook(
14
+ () => useRecordOverride('posts', 'post-1', 'title'),
15
+ { wrapper },
16
+ )
17
+ expect(result.current).toHaveLength(3)
18
+ expect(typeof result.current[1]).toBe('function')
19
+ expect(typeof result.current[2]).toBe('function')
20
+ })
21
+
22
+ it('builds correct override path (record.posts.post-1.title)', () => {
23
+ const { result } = renderHook(
24
+ () => useRecordOverride('posts', 'post-1', 'title'),
25
+ { wrapper },
26
+ )
27
+ // scene data has record.posts.post-1.title = 'Original Title'
28
+ expect(result.current[0]).toBe('Original Title')
29
+ })
30
+
31
+ it('returns undefined when path does not exist in scene data', () => {
32
+ const { result } = renderHook(
33
+ () => useRecordOverride('posts', 'post-99', 'title'),
34
+ { wrapper },
35
+ )
36
+ expect(result.current[0]).toBeUndefined()
37
+ })
38
+
39
+ it('setValue writes to hash at the correct path', () => {
40
+ const { result } = renderHook(
41
+ () => useRecordOverride('posts', 'post-1', 'title'),
42
+ { wrapper },
43
+ )
44
+
45
+ act(() => {
46
+ result.current[1]('New Title')
47
+ })
48
+
49
+ const params = new URLSearchParams(window.location.hash.replace(/^#/, ''))
50
+ expect(params.get('record.posts.post-1.title')).toBe('New Title')
51
+ })
52
+ })
@@ -0,0 +1,28 @@
1
+ import { useContext, useCallback } from 'react'
2
+ import { StoryboardContext } from '../StoryboardContext.js'
3
+
4
+ /**
5
+ * Read the current scene name and programmatically switch scenes.
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
10
+ */
11
+ export function useScene() {
12
+ const context = useContext(StoryboardContext)
13
+ if (context === null) {
14
+ throw new Error('useScene must be used within a <StoryboardProvider>')
15
+ }
16
+
17
+ const switchScene = useCallback((name) => {
18
+ const url = new URL(window.location.href)
19
+ url.searchParams.set('scene', name)
20
+ // Preserve hash params across scene switches
21
+ window.location.href = url.toString()
22
+ }, [])
23
+
24
+ return {
25
+ sceneName: context.sceneName,
26
+ switchScene,
27
+ }
28
+ }
@@ -0,0 +1,39 @@
1
+ import { renderHook } from '@testing-library/react'
2
+ import { useScene } from './useScene.js'
3
+ import { seedTestData, createWrapper, TEST_SCENES } from '../test-utils.js'
4
+
5
+ const sceneData = TEST_SCENES.default
6
+
7
+ beforeEach(() => {
8
+ seedTestData()
9
+ })
10
+
11
+ describe('useScene', () => {
12
+ it('returns { sceneName, switchScene }', () => {
13
+ const { result } = renderHook(() => useScene(), {
14
+ wrapper: createWrapper(sceneData),
15
+ })
16
+ expect(result.current).toHaveProperty('sceneName')
17
+ expect(result.current).toHaveProperty('switchScene')
18
+ })
19
+
20
+ it('sceneName matches the value from context', () => {
21
+ const { result } = renderHook(() => useScene(), {
22
+ wrapper: createWrapper(sceneData, 'other'),
23
+ })
24
+ expect(result.current.sceneName).toBe('other')
25
+ })
26
+
27
+ it('switchScene is a function', () => {
28
+ const { result } = renderHook(() => useScene(), {
29
+ wrapper: createWrapper(sceneData),
30
+ })
31
+ expect(typeof result.current.switchScene).toBe('function')
32
+ })
33
+
34
+ it('throws when used outside StoryboardProvider', () => {
35
+ expect(() => {
36
+ renderHook(() => useScene())
37
+ }).toThrow('useScene must be used within a <StoryboardProvider>')
38
+ })
39
+ })
@@ -0,0 +1,97 @@
1
+ import { useContext, useMemo, useSyncExternalStore } from 'react'
2
+ import { StoryboardContext } from '../StoryboardContext.js'
3
+ import { getByPath, deepClone, setByPath } from '@dfosco/storyboard-core'
4
+ import { getParam, getAllParams } from '@dfosco/storyboard-core'
5
+ import { subscribeToHash, getHashSnapshot } from '@dfosco/storyboard-core'
6
+ import { isHideMode, getShadow, getAllShadows } from '@dfosco/storyboard-core'
7
+ import { subscribeToStorage, getStorageSnapshot } from '@dfosco/storyboard-core'
8
+
9
+ /**
10
+ * Access scene data by dot-notation path.
11
+ * Hash params override scene data — both exact matches and nested paths.
12
+ *
13
+ * Examples:
14
+ * useSceneData('user.name') with #user.name=Alice → "Alice"
15
+ * useSceneData('repositories') with #repositories.0.name=Foo
16
+ * → deep clone of repositories array with [0].name overridden to "Foo"
17
+ *
18
+ * @param {string} [path] - Dot-notation path (e.g. 'user.profile.name').
19
+ * Omit to get the entire scene object.
20
+ * @returns {*} The resolved value. Returns {} if path is missing after loading.
21
+ * @throws If used outside a StoryboardProvider.
22
+ */
23
+ export function useSceneData(path) {
24
+ const context = useContext(StoryboardContext)
25
+
26
+ if (context === null) {
27
+ throw new Error('useSceneData must be used within a <StoryboardProvider>')
28
+ }
29
+
30
+ const { data, loading, error } = context
31
+
32
+ // Re-render on any hash or localStorage change
33
+ const hashString = useSyncExternalStore(subscribeToHash, getHashSnapshot)
34
+ const storageString = useSyncExternalStore(subscribeToStorage, getStorageSnapshot)
35
+
36
+ // Collect overrides relevant to this path
37
+ const result = useMemo(() => {
38
+ if (loading || error || data == null) return undefined
39
+
40
+ const hidden = isHideMode()
41
+ // In hide mode, read from shadow localStorage; otherwise from URL hash
42
+ const readParam = hidden ? getShadow : getParam
43
+ const readAllParams = hidden ? getAllShadows : getAllParams
44
+
45
+ if (!path) {
46
+ // No path → return full scene data with all overrides applied
47
+ const allParams = readAllParams()
48
+ const keys = Object.keys(allParams)
49
+ if (keys.length === 0) return data
50
+ const merged = deepClone(data)
51
+ for (const key of keys) setByPath(merged, key, allParams[key])
52
+ return merged
53
+ }
54
+
55
+ // Exact match: param directly for this path
56
+ const exact = readParam(path)
57
+ if (exact !== null) return exact
58
+
59
+ // Child overrides: params that are nested under this path
60
+ const prefix = path + '.'
61
+ const allParams = readAllParams()
62
+ const childKeys = Object.keys(allParams).filter(k => k.startsWith(prefix))
63
+
64
+ const sceneValue = getByPath(data, path)
65
+
66
+ if (childKeys.length > 0 && sceneValue !== undefined) {
67
+ const merged = deepClone(sceneValue)
68
+ for (const key of childKeys) {
69
+ const relativePath = key.slice(prefix.length)
70
+ setByPath(merged, relativePath, allParams[key])
71
+ }
72
+ return merged
73
+ }
74
+
75
+ if (sceneValue === undefined) {
76
+ console.warn(`[useSceneData] Path "${path}" not found in scene data.`)
77
+ return {}
78
+ }
79
+
80
+ return sceneValue
81
+ }, [data, loading, error, path, hashString, storageString]) // eslint-disable-line react-hooks/exhaustive-deps
82
+
83
+ return result
84
+ }
85
+
86
+ /**
87
+ * Returns true while scene data is still loading.
88
+ */
89
+ export function useSceneLoading() {
90
+ const context = useContext(StoryboardContext)
91
+
92
+ if (context === null) {
93
+ throw new Error('useSceneLoading must be used within a <StoryboardProvider>')
94
+ }
95
+
96
+ return context.loading
97
+ }