@rlabs-inc/tui 0.1.0 → 0.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.
Files changed (38) hide show
  1. package/README.md +126 -13
  2. package/index.ts +11 -5
  3. package/package.json +2 -2
  4. package/src/api/mount.ts +42 -27
  5. package/src/engine/arrays/core.ts +13 -21
  6. package/src/engine/arrays/dimensions.ts +22 -32
  7. package/src/engine/arrays/index.ts +88 -86
  8. package/src/engine/arrays/interaction.ts +34 -48
  9. package/src/engine/arrays/layout.ts +67 -92
  10. package/src/engine/arrays/spacing.ts +37 -52
  11. package/src/engine/arrays/text.ts +23 -31
  12. package/src/engine/arrays/visual.ts +56 -75
  13. package/src/engine/inheritance.ts +18 -18
  14. package/src/engine/registry.ts +15 -0
  15. package/src/pipeline/frameBuffer.ts +26 -26
  16. package/src/pipeline/layout/index.ts +2 -2
  17. package/src/pipeline/layout/titan-engine.ts +112 -84
  18. package/src/primitives/animation.ts +194 -0
  19. package/src/primitives/box.ts +74 -86
  20. package/src/primitives/each.ts +87 -0
  21. package/src/primitives/index.ts +7 -0
  22. package/src/primitives/scope.ts +215 -0
  23. package/src/primitives/show.ts +77 -0
  24. package/src/primitives/text.ts +63 -59
  25. package/src/primitives/types.ts +1 -1
  26. package/src/primitives/when.ts +102 -0
  27. package/src/renderer/append-region.ts +303 -0
  28. package/src/renderer/index.ts +4 -2
  29. package/src/renderer/output.ts +11 -34
  30. package/src/state/focus.ts +16 -5
  31. package/src/state/global-keys.ts +184 -0
  32. package/src/state/index.ts +44 -8
  33. package/src/state/input.ts +534 -0
  34. package/src/state/keyboard.ts +98 -674
  35. package/src/state/mouse.ts +163 -340
  36. package/src/state/scroll.ts +7 -9
  37. package/src/types/index.ts +6 -0
  38. package/src/renderer/input.ts +0 -518
@@ -0,0 +1,215 @@
1
+ /**
2
+ * TUI Framework - Component Scope
3
+ *
4
+ * Automatic cleanup collection for component functions.
5
+ * box/text/effect automatically register with the active scope.
6
+ *
7
+ * Usage:
8
+ * ```ts
9
+ * function MyComponent(props): Cleanup {
10
+ * return scoped(() => {
11
+ * // Everything inside auto-registers cleanup
12
+ * box({ children: () => text({ content: 'Hello' }) })
13
+ * effect(() => console.log(count.value))
14
+ *
15
+ * // Manual cleanup for timers etc
16
+ * const timer = setInterval(() => tick(), 1000)
17
+ * onCleanup(() => clearInterval(timer))
18
+ * })
19
+ * }
20
+ * ```
21
+ */
22
+
23
+ import { effectScope, onScopeDispose } from '@rlabs-inc/signals'
24
+ import type { EffectScope } from '@rlabs-inc/signals'
25
+
26
+ export type Cleanup = () => void
27
+
28
+ // =============================================================================
29
+ // ACTIVE SCOPE TRACKING
30
+ // =============================================================================
31
+
32
+ interface ScopeContext {
33
+ cleanups: Cleanup[]
34
+ scope: EffectScope
35
+ }
36
+
37
+ let activeContext: ScopeContext | null = null
38
+
39
+ /**
40
+ * Get the currently active scope context.
41
+ * Used by box/text to auto-register cleanups.
42
+ */
43
+ export function getActiveScope(): ScopeContext | null {
44
+ return activeContext
45
+ }
46
+
47
+ /**
48
+ * Register a cleanup with the active scope.
49
+ * Called automatically by box/text when a scope is active.
50
+ * Can also be called manually for timers/subscriptions.
51
+ */
52
+ export function onCleanup(cleanup: Cleanup): void {
53
+ if (activeContext) {
54
+ activeContext.cleanups.push(cleanup)
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Register a cleanup and return it (for chaining).
60
+ * Useful when you need the cleanup function reference.
61
+ */
62
+ export function trackCleanup<T extends Cleanup>(cleanup: T): T {
63
+ if (activeContext) {
64
+ activeContext.cleanups.push(cleanup)
65
+ }
66
+ return cleanup
67
+ }
68
+
69
+ // =============================================================================
70
+ // SCOPED EXECUTION
71
+ // =============================================================================
72
+
73
+ /**
74
+ * Execute a function with automatic cleanup collection.
75
+ * box/text/effect calls inside automatically register their cleanups.
76
+ *
77
+ * @example Basic component
78
+ * ```ts
79
+ * function Counter(): Cleanup {
80
+ * return scoped(() => {
81
+ * const count = signal(0)
82
+ *
83
+ * box({
84
+ * children: () => {
85
+ * text({ content: () => `Count: ${count.value}` })
86
+ * }
87
+ * })
88
+ *
89
+ * effect(() => console.log('Count:', count.value))
90
+ * })
91
+ * }
92
+ * ```
93
+ *
94
+ * @example With manual cleanups
95
+ * ```ts
96
+ * function Timer(): Cleanup {
97
+ * return scoped(() => {
98
+ * const elapsed = signal(0)
99
+ *
100
+ * const interval = setInterval(() => elapsed.value++, 1000)
101
+ * onCleanup(() => clearInterval(interval))
102
+ *
103
+ * box({ children: () => text({ content: () => `${elapsed.value}s` }) })
104
+ * })
105
+ * }
106
+ * ```
107
+ *
108
+ * @example Nested components work too
109
+ * ```ts
110
+ * function Parent(): Cleanup {
111
+ * return scoped(() => {
112
+ * box({
113
+ * children: () => {
114
+ * Child() // Child's cleanup is tracked by parent
115
+ * }
116
+ * })
117
+ * })
118
+ * }
119
+ * ```
120
+ */
121
+ export function scoped(fn: () => void): Cleanup {
122
+ const scope = effectScope()
123
+ const cleanups: Cleanup[] = []
124
+
125
+ const prevContext = activeContext
126
+ activeContext = { cleanups, scope }
127
+
128
+ try {
129
+ // Run the function within effect scope
130
+ // Effects created inside are tracked by the scope
131
+ scope.run(fn)
132
+ } finally {
133
+ activeContext = prevContext
134
+ }
135
+
136
+ // Return master cleanup
137
+ return () => {
138
+ // Stop effect scope (disposes effects)
139
+ scope.stop()
140
+
141
+ // Run all registered cleanups
142
+ for (const cleanup of cleanups) {
143
+ try {
144
+ cleanup()
145
+ } catch (e) {
146
+ // Cleanup errors logged but don't stop other cleanups
147
+ console.error('Cleanup error:', e)
148
+ }
149
+ }
150
+ }
151
+ }
152
+
153
+ // =============================================================================
154
+ // LEGACY API (for backwards compatibility)
155
+ // =============================================================================
156
+
157
+ export interface ComponentScopeResult {
158
+ onCleanup: <T extends Cleanup>(cleanup: T) => T
159
+ cleanup: Cleanup
160
+ scope: EffectScope
161
+ }
162
+
163
+ /**
164
+ * Create a component scope for manual cleanup collection.
165
+ * @deprecated Use scoped() for automatic cleanup instead
166
+ */
167
+ export function componentScope(): ComponentScopeResult {
168
+ const cleanups: Cleanup[] = []
169
+ const scope = effectScope()
170
+
171
+ return {
172
+ onCleanup: <T extends Cleanup>(cleanup: T): T => {
173
+ cleanups.push(cleanup)
174
+ return cleanup
175
+ },
176
+ cleanup: () => {
177
+ scope.stop()
178
+ for (const cleanup of cleanups) {
179
+ try {
180
+ cleanup()
181
+ } catch (e) {
182
+ console.error('Cleanup error:', e)
183
+ }
184
+ }
185
+ },
186
+ scope,
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Create a simple cleanup collector.
192
+ * @deprecated Use scoped() for automatic cleanup instead
193
+ */
194
+ export function cleanupCollector(): {
195
+ add: <T extends Cleanup>(cleanup: T) => T
196
+ cleanup: Cleanup
197
+ } {
198
+ const cleanups: Cleanup[] = []
199
+
200
+ return {
201
+ add: <T extends Cleanup>(cleanup: T): T => {
202
+ cleanups.push(cleanup)
203
+ return cleanup
204
+ },
205
+ cleanup: () => {
206
+ for (const cleanup of cleanups) {
207
+ try {
208
+ cleanup()
209
+ } catch (e) {
210
+ console.error('Cleanup error:', e)
211
+ }
212
+ }
213
+ },
214
+ }
215
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * TUI Framework - Show Primitive
3
+ *
4
+ * Conditional rendering. Shows or hides components based on a condition.
5
+ * When the condition changes, components are automatically created/destroyed.
6
+ *
7
+ * Usage:
8
+ * ```ts
9
+ * show(() => isVisible.value, () => {
10
+ * text({ content: 'I am visible!' })
11
+ * })
12
+ * ```
13
+ */
14
+
15
+ import { effect, effectScope, onScopeDispose } from '@rlabs-inc/signals'
16
+ import { getCurrentParentIndex, pushParentContext, popParentContext } from '../engine/registry'
17
+ import type { Cleanup } from './types'
18
+
19
+ /**
20
+ * Conditionally render components.
21
+ *
22
+ * @param conditionGetter - Getter that returns boolean (creates dependency)
23
+ * @param renderFn - Function to render when condition is true (returns cleanup)
24
+ * @param elseFn - Optional function to render when condition is false
25
+ */
26
+ export function show(
27
+ conditionGetter: () => boolean,
28
+ renderFn: () => Cleanup,
29
+ elseFn?: () => Cleanup
30
+ ): Cleanup {
31
+ let cleanup: Cleanup | null = null
32
+ let wasTrue: boolean | null = null
33
+ const parentIndex = getCurrentParentIndex()
34
+ const scope = effectScope()
35
+
36
+ const update = (condition: boolean) => {
37
+ if (condition === wasTrue) return
38
+ wasTrue = condition
39
+
40
+ if (cleanup) {
41
+ cleanup()
42
+ cleanup = null
43
+ }
44
+
45
+ pushParentContext(parentIndex)
46
+ try {
47
+ if (condition) {
48
+ cleanup = renderFn()
49
+ } else if (elseFn) {
50
+ cleanup = elseFn()
51
+ }
52
+ } finally {
53
+ popParentContext()
54
+ }
55
+ }
56
+
57
+ scope.run(() => {
58
+ // Initial render
59
+ update(conditionGetter())
60
+
61
+ // Effect for updates - skip first run
62
+ let initialized = false
63
+ effect(() => {
64
+ const condition = conditionGetter()
65
+ if (initialized) {
66
+ update(condition)
67
+ }
68
+ initialized = true
69
+ })
70
+
71
+ onScopeDispose(() => {
72
+ if (cleanup) cleanup()
73
+ })
74
+ })
75
+
76
+ return () => scope.stop()
77
+ }
@@ -23,11 +23,12 @@
23
23
  * ```
24
24
  */
25
25
 
26
- import { bind, BINDING_SYMBOL } from '@rlabs-inc/signals'
26
+ // Using slotArray APIs - no direct signal imports needed
27
27
  import { ComponentType, Attr } from '../types'
28
28
  import { allocateIndex, releaseIndex, getCurrentParentIndex } from '../engine/registry'
29
29
  import { cleanupIndex as cleanupKeyboardListeners } from '../state/keyboard'
30
30
  import { getVariantStyle } from '../state/theme'
31
+ import { getActiveScope } from './scope'
31
32
 
32
33
  // Import arrays
33
34
  import * as core from '../engine/arrays/core'
@@ -61,31 +62,33 @@ function wrapToNum(wrap: string | undefined): number {
61
62
  default: return 1 // wrap
62
63
  }
63
64
  }
65
+ /** Override alignItems for this item: 0=auto, 1=stretch, 2=flex-start, 3=center, 4=flex-end */
66
+ function alignSelfToNum(alignSelf: string | undefined): number {
67
+ switch (alignSelf) {
68
+ case 'auto': return 0
69
+ case 'stretch': return 1
70
+ case 'flex-start': return 2
71
+ case 'center': return 3
72
+ case 'flex-end': return 4
73
+ default: return 0
74
+ }
75
+ }
76
+
77
+ // bindEnumProp removed - using enumSource instead
64
78
 
65
79
  /**
66
- * Create a binding for enum props that converts at read time.
67
- * No derived needed - reads signal directly and converts inline.
68
- * This creates dependency directly on user's signal, no intermediate objects.
80
+ * Create a slot source for enum props - returns getter for reactive, value for static.
81
+ * For use with slotArray.setSource()
69
82
  */
70
- function bindEnumProp<T extends string>(
83
+ function enumSource<T extends string>(
71
84
  prop: T | { value: T } | undefined,
72
85
  converter: (val: T | undefined) => number
73
- ): ReturnType<typeof bind<number>> {
74
- // If it's reactive (has .value), create binding that converts at read time
86
+ ): number | (() => number) {
75
87
  if (prop !== undefined && typeof prop === 'object' && prop !== null && 'value' in prop) {
76
88
  const reactiveSource = prop as { value: T }
77
- return {
78
- [BINDING_SYMBOL]: true,
79
- get value(): number {
80
- return converter(reactiveSource.value)
81
- },
82
- set value(_: number) {
83
- // Enum props are read-only from number side
84
- },
85
- } as unknown as ReturnType<typeof bind<number>>
89
+ return () => converter(reactiveSource.value)
86
90
  }
87
- // Static value - just convert
88
- return bind(converter(prop as T | undefined))
91
+ return converter(prop as T | undefined)
89
92
  }
90
93
 
91
94
  // =============================================================================
@@ -110,55 +113,56 @@ export function text(props: TextProps): Cleanup {
110
113
  // CORE - Always needed
111
114
  // ==========================================================================
112
115
  core.componentType[index] = ComponentType.TEXT
113
- core.parentIndex[index] = bind(getCurrentParentIndex())
116
+ core.parentIndex.setSource(index, getCurrentParentIndex())
114
117
 
115
118
  // Visible - only bind if passed
116
119
  if (props.visible !== undefined) {
117
- core.visible[index] = bind(props.visible)
120
+ core.visible.setSource(index, props.visible)
118
121
  }
119
122
 
120
123
  // ==========================================================================
121
124
  // TEXT CONTENT - Always needed (this is a text component!)
125
+ // Uses setSource() for stable slot tracking (fixes bind() replacement bug)
122
126
  // ==========================================================================
123
- textArrays.textContent[index] = bind(props.content)
127
+ textArrays.textContent.setSource(index, props.content)
124
128
 
125
- // Text styling - only bind if passed
126
- if (props.attrs !== undefined) textArrays.textAttrs[index] = bind(props.attrs)
127
- if (props.align !== undefined) textArrays.textAlign[index] = bindEnumProp(props.align, alignToNum)
128
- if (props.wrap !== undefined) textArrays.textWrap[index] = bindEnumProp(props.wrap, wrapToNum)
129
+ // Text styling - only set if passed
130
+ if (props.attrs !== undefined) textArrays.textAttrs.setSource(index, props.attrs)
131
+ if (props.align !== undefined) textArrays.textAlign.setSource(index, enumSource(props.align, alignToNum))
132
+ if (props.wrap !== undefined) textArrays.textWrap.setSource(index, enumSource(props.wrap, wrapToNum))
129
133
 
130
134
  // ==========================================================================
131
135
  // DIMENSIONS - Only bind what's passed (TITAN uses ?? 0 for undefined)
132
136
  // ==========================================================================
133
- if (props.width !== undefined) dimensions.width[index] = bind(props.width)
134
- if (props.height !== undefined) dimensions.height[index] = bind(props.height)
135
- if (props.minWidth !== undefined) dimensions.minWidth[index] = bind(props.minWidth)
136
- if (props.maxWidth !== undefined) dimensions.maxWidth[index] = bind(props.maxWidth)
137
- if (props.minHeight !== undefined) dimensions.minHeight[index] = bind(props.minHeight)
138
- if (props.maxHeight !== undefined) dimensions.maxHeight[index] = bind(props.maxHeight)
137
+ if (props.width !== undefined) dimensions.width.setSource(index, props.width)
138
+ if (props.height !== undefined) dimensions.height.setSource(index, props.height)
139
+ if (props.minWidth !== undefined) dimensions.minWidth.setSource(index, props.minWidth)
140
+ if (props.maxWidth !== undefined) dimensions.maxWidth.setSource(index, props.maxWidth)
141
+ if (props.minHeight !== undefined) dimensions.minHeight.setSource(index, props.minHeight)
142
+ if (props.maxHeight !== undefined) dimensions.maxHeight.setSource(index, props.maxHeight)
139
143
 
140
144
  // ==========================================================================
141
145
  // PADDING - Shorthand support
142
146
  // ==========================================================================
143
147
  if (props.padding !== undefined) {
144
- spacing.paddingTop[index] = bind(props.paddingTop ?? props.padding)
145
- spacing.paddingRight[index] = bind(props.paddingRight ?? props.padding)
146
- spacing.paddingBottom[index] = bind(props.paddingBottom ?? props.padding)
147
- spacing.paddingLeft[index] = bind(props.paddingLeft ?? props.padding)
148
+ spacing.paddingTop.setSource(index, props.paddingTop ?? props.padding)
149
+ spacing.paddingRight.setSource(index, props.paddingRight ?? props.padding)
150
+ spacing.paddingBottom.setSource(index, props.paddingBottom ?? props.padding)
151
+ spacing.paddingLeft.setSource(index, props.paddingLeft ?? props.padding)
148
152
  } else {
149
- if (props.paddingTop !== undefined) spacing.paddingTop[index] = bind(props.paddingTop)
150
- if (props.paddingRight !== undefined) spacing.paddingRight[index] = bind(props.paddingRight)
151
- if (props.paddingBottom !== undefined) spacing.paddingBottom[index] = bind(props.paddingBottom)
152
- if (props.paddingLeft !== undefined) spacing.paddingLeft[index] = bind(props.paddingLeft)
153
+ if (props.paddingTop !== undefined) spacing.paddingTop.setSource(index, props.paddingTop)
154
+ if (props.paddingRight !== undefined) spacing.paddingRight.setSource(index, props.paddingRight)
155
+ if (props.paddingBottom !== undefined) spacing.paddingBottom.setSource(index, props.paddingBottom)
156
+ if (props.paddingLeft !== undefined) spacing.paddingLeft.setSource(index, props.paddingLeft)
153
157
  }
154
158
 
155
159
  // ==========================================================================
156
160
  // FLEX ITEM - Only bind if passed (text can be a flex item)
157
161
  // ==========================================================================
158
- if (props.grow !== undefined) layout.flexGrow[index] = bind(props.grow)
159
- if (props.shrink !== undefined) layout.flexShrink[index] = bind(props.shrink)
160
- if (props.flexBasis !== undefined) layout.flexBasis[index] = bind(props.flexBasis)
161
- if (props.alignSelf !== undefined) layout.alignSelf[index] = bind(props.alignSelf)
162
+ if (props.grow !== undefined) layout.flexGrow.setSource(index, props.grow)
163
+ if (props.shrink !== undefined) layout.flexShrink.setSource(index, props.shrink)
164
+ if (props.flexBasis !== undefined) layout.flexBasis.setSource(index, props.flexBasis)
165
+ if (props.alignSelf !== undefined) layout.alignSelf.setSource(index, enumSource(props.alignSelf, alignSelfToNum))
162
166
 
163
167
  // ==========================================================================
164
168
  // VISUAL - Colors with variant support (only bind what's needed)
@@ -167,33 +171,33 @@ export function text(props: TextProps): Cleanup {
167
171
  // Variant colors - inline bindings that read theme at read time (no deriveds!)
168
172
  const variant = props.variant
169
173
  if (props.fg !== undefined) {
170
- visual.fgColor[index] = bind(props.fg)
174
+ visual.fgColor.setSource(index, props.fg)
171
175
  } else {
172
- visual.fgColor[index] = {
173
- [BINDING_SYMBOL]: true,
174
- get value() { return getVariantStyle(variant).fg },
175
- set value(_) {},
176
- } as any
176
+ visual.fgColor.setSource(index, () => getVariantStyle(variant).fg)
177
177
  }
178
178
  if (props.bg !== undefined) {
179
- visual.bgColor[index] = bind(props.bg)
179
+ visual.bgColor.setSource(index, props.bg)
180
180
  } else {
181
- visual.bgColor[index] = {
182
- [BINDING_SYMBOL]: true,
183
- get value() { return getVariantStyle(variant).bg },
184
- set value(_) {},
185
- } as any
181
+ visual.bgColor.setSource(index, () => getVariantStyle(variant).bg)
186
182
  }
187
183
  } else {
188
184
  // Direct colors - only bind if passed
189
- if (props.fg !== undefined) visual.fgColor[index] = bind(props.fg)
190
- if (props.bg !== undefined) visual.bgColor[index] = bind(props.bg)
185
+ if (props.fg !== undefined) visual.fgColor.setSource(index, props.fg)
186
+ if (props.bg !== undefined) visual.bgColor.setSource(index, props.bg)
191
187
  }
192
- if (props.opacity !== undefined) visual.opacity[index] = bind(props.opacity)
188
+ if (props.opacity !== undefined) visual.opacity.setSource(index, props.opacity)
193
189
 
194
190
  // Cleanup function
195
- return () => {
191
+ const cleanup = () => {
196
192
  cleanupKeyboardListeners(index)
197
193
  releaseIndex(index)
198
194
  }
195
+
196
+ // Auto-register with active scope if one exists
197
+ const scope = getActiveScope()
198
+ if (scope) {
199
+ scope.cleanups.push(cleanup)
200
+ }
201
+
202
+ return cleanup
199
203
  }
@@ -137,7 +137,7 @@ export interface BoxProps extends StyleProps, BorderProps, DimensionProps, Spaci
137
137
  // TEXT PROPS
138
138
  // =============================================================================
139
139
 
140
- export interface TextProps extends StyleProps, DimensionProps, SpacingProps {
140
+ export interface TextProps extends StyleProps, DimensionProps, SpacingProps, LayoutProps {
141
141
  /** Text content */
142
142
  content: Reactive<string>
143
143
  /** Text alignment: 'left' | 'center' | 'right' */
@@ -0,0 +1,102 @@
1
+ /**
2
+ * TUI Framework - When Primitive
3
+ *
4
+ * Async rendering. Shows loading/error/success states based on promise state.
5
+ * When the promise resolves or rejects, the appropriate components are shown.
6
+ *
7
+ * Usage:
8
+ * ```ts
9
+ * when(() => fetchData(), {
10
+ * pending: () => text({ content: 'Loading...' }),
11
+ * then: (data) => text({ content: `Got: ${data}` }),
12
+ * catch: (err) => text({ content: `Error: ${err.message}` })
13
+ * })
14
+ * ```
15
+ */
16
+
17
+ import { effect, effectScope, onScopeDispose } from '@rlabs-inc/signals'
18
+ import { getCurrentParentIndex, pushParentContext, popParentContext } from '../engine/registry'
19
+ import type { Cleanup } from './types'
20
+
21
+ interface WhenOptions<T> {
22
+ pending?: () => Cleanup
23
+ then: (value: T) => Cleanup
24
+ catch?: (error: Error) => Cleanup
25
+ }
26
+
27
+ /**
28
+ * Render based on async promise state.
29
+ *
30
+ * @param promiseGetter - Getter that returns a promise (creates dependency)
31
+ * @param options - Handlers for pending, then, and catch states
32
+ */
33
+ export function when<T>(
34
+ promiseGetter: () => Promise<T>,
35
+ options: WhenOptions<T>
36
+ ): Cleanup {
37
+ let cleanup: Cleanup | null = null
38
+ let currentPromise: Promise<T> | null = null
39
+ const parentIndex = getCurrentParentIndex()
40
+ const scope = effectScope()
41
+
42
+ const render = (fn: () => Cleanup) => {
43
+ if (cleanup) {
44
+ cleanup()
45
+ cleanup = null
46
+ }
47
+ pushParentContext(parentIndex)
48
+ try {
49
+ cleanup = fn()
50
+ } finally {
51
+ popParentContext()
52
+ }
53
+ }
54
+
55
+ const handlePromise = (promise: Promise<T>) => {
56
+ if (promise !== currentPromise) return
57
+
58
+ promise
59
+ .then((value) => {
60
+ if (promise !== currentPromise) return
61
+ render(() => options.then(value))
62
+ })
63
+ .catch((error) => {
64
+ if (promise !== currentPromise) return
65
+ if (options.catch) {
66
+ render(() => options.catch!(error))
67
+ }
68
+ })
69
+ }
70
+
71
+ scope.run(() => {
72
+ // Initial setup
73
+ const initialPromise = promiseGetter()
74
+ currentPromise = initialPromise
75
+ if (options.pending) {
76
+ render(options.pending)
77
+ }
78
+ handlePromise(initialPromise)
79
+
80
+ // Effect for updates - skip first run
81
+ let initialized = false
82
+ effect(() => {
83
+ const promise = promiseGetter()
84
+ if (initialized) {
85
+ if (promise === currentPromise) return
86
+ currentPromise = promise
87
+ if (options.pending) {
88
+ render(options.pending)
89
+ }
90
+ handlePromise(promise)
91
+ }
92
+ initialized = true
93
+ })
94
+
95
+ onScopeDispose(() => {
96
+ currentPromise = null
97
+ if (cleanup) cleanup()
98
+ })
99
+ })
100
+
101
+ return () => scope.stop()
102
+ }