@fictjs/runtime 0.5.0 → 0.5.2

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 (51) hide show
  1. package/dist/advanced.cjs +9 -9
  2. package/dist/advanced.js +4 -4
  3. package/dist/{chunk-5AA7HP4S.js → chunk-4NUHM77Z.js} +3 -3
  4. package/dist/{chunk-BQG7VEBY.js → chunk-D2IWOO4X.js} +2 -2
  5. package/dist/{chunk-KYLNC4CD.cjs → chunk-KNGHYGK4.cjs} +17 -17
  6. package/dist/{chunk-KYLNC4CD.cjs.map → chunk-KNGHYGK4.cjs.map} +1 -1
  7. package/dist/{chunk-FKDMDAUR.js → chunk-LRFMCJY3.js} +119 -19
  8. package/dist/chunk-LRFMCJY3.js.map +1 -0
  9. package/dist/{chunk-GHUV2FLD.cjs → chunk-QB2UD62G.cjs} +8 -8
  10. package/dist/{chunk-GHUV2FLD.cjs.map → chunk-QB2UD62G.cjs.map} +1 -1
  11. package/dist/{chunk-KKKYW54Z.js → chunk-SLFAEVKJ.js} +3 -3
  12. package/dist/{chunk-TKWN42TA.cjs → chunk-Z6M3HKLG.cjs} +156 -156
  13. package/dist/{chunk-TKWN42TA.cjs.map → chunk-Z6M3HKLG.cjs.map} +1 -1
  14. package/dist/{chunk-6SOPF5LZ.cjs → chunk-ZR435MDC.cjs} +120 -20
  15. package/dist/chunk-ZR435MDC.cjs.map +1 -0
  16. package/dist/index.cjs +95 -45
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.dev.js +120 -25
  19. package/dist/index.dev.js.map +1 -1
  20. package/dist/index.js +60 -10
  21. package/dist/index.js.map +1 -1
  22. package/dist/internal.cjs +64 -42
  23. package/dist/internal.cjs.map +1 -1
  24. package/dist/internal.d.cts +12 -3
  25. package/dist/internal.d.ts +12 -3
  26. package/dist/internal.js +25 -3
  27. package/dist/internal.js.map +1 -1
  28. package/dist/jsx-dev-runtime.d.cts +671 -0
  29. package/dist/jsx-dev-runtime.d.ts +671 -0
  30. package/dist/loader.cjs +60 -8
  31. package/dist/loader.cjs.map +1 -1
  32. package/dist/loader.d.cts +1 -1
  33. package/dist/loader.d.ts +1 -1
  34. package/dist/loader.js +53 -1
  35. package/dist/loader.js.map +1 -1
  36. package/dist/{resume-Dx8_l72o.d.ts → resume-CqeQ3v_q.d.ts} +5 -1
  37. package/dist/{resume-BrAkmSTY.d.cts → resume-i-A3EFox.d.cts} +5 -1
  38. package/package.json +1 -1
  39. package/src/cycle-guard.ts +1 -1
  40. package/src/internal.ts +4 -0
  41. package/src/list-helpers.ts +19 -4
  42. package/src/loader.ts +58 -0
  43. package/src/resume.ts +55 -0
  44. package/src/signal.ts +47 -22
  45. package/src/ssr-stream.ts +38 -0
  46. package/src/suspense.ts +62 -7
  47. package/dist/chunk-6SOPF5LZ.cjs.map +0 -1
  48. package/dist/chunk-FKDMDAUR.js.map +0 -1
  49. /package/dist/{chunk-5AA7HP4S.js.map → chunk-4NUHM77Z.js.map} +0 -0
  50. /package/dist/{chunk-BQG7VEBY.js.map → chunk-D2IWOO4X.js.map} +0 -0
  51. /package/dist/{chunk-KKKYW54Z.js.map → chunk-SLFAEVKJ.js.map} +0 -0
package/src/loader.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  __fictEnsureScope,
5
5
  __fictGetResume,
6
6
  __fictGetSSRScope,
7
+ __fictMergeSSRState,
7
8
  __fictSetSSRState,
8
9
  __fictUseLexicalScope,
9
10
  } from './resume'
@@ -82,6 +83,8 @@ const hydratedScopes = new Set<string>()
82
83
  const prefetchedUrls = new Set<string>()
83
84
  let prefetchCleanup: (() => void) | null = null
84
85
  let eventListenerCleanup: (() => void) | null = null
86
+ let snapshotObserver: MutationObserver | null = null
87
+ const processedSnapshots = new Set<HTMLScriptElement>()
85
88
 
86
89
  /**
87
90
  * Reset the hydrated scopes set. Useful for testing.
@@ -131,6 +134,7 @@ export function installResumableLoader(options: ResumableLoaderOptions = {}): vo
131
134
  // Reset hydrated scopes for fresh loader installation
132
135
  hydratedScopes.clear()
133
136
  prefetchedUrls.clear()
137
+ processedSnapshots.clear()
134
138
 
135
139
  // Clean up previous event listeners
136
140
  if (eventListenerCleanup) {
@@ -144,6 +148,11 @@ export function installResumableLoader(options: ResumableLoaderOptions = {}): vo
144
148
  prefetchCleanup = null
145
149
  }
146
150
 
151
+ if (snapshotObserver) {
152
+ snapshotObserver.disconnect()
153
+ snapshotObserver = null
154
+ }
155
+
147
156
  const snapshotEl = doc.getElementById(scriptId)
148
157
  if (snapshotEl?.textContent) {
149
158
  try {
@@ -154,6 +163,38 @@ export function installResumableLoader(options: ResumableLoaderOptions = {}): vo
154
163
  }
155
164
  }
156
165
 
166
+ const snapshotScripts = doc.querySelectorAll(
167
+ 'script[type="application/json"][data-fict-snapshot]',
168
+ )
169
+ for (const script of Array.from(snapshotScripts)) {
170
+ parseSnapshotScript(script as HTMLScriptElement)
171
+ }
172
+
173
+ if (typeof MutationObserver !== 'undefined') {
174
+ snapshotObserver = new MutationObserver(mutations => {
175
+ for (const mutation of mutations) {
176
+ for (const node of Array.from(mutation.addedNodes)) {
177
+ if (!(node instanceof Element)) continue
178
+ if (node.tagName === 'SCRIPT') {
179
+ const script = node as HTMLScriptElement
180
+ if (isSnapshotScript(script)) {
181
+ parseSnapshotScript(script)
182
+ }
183
+ }
184
+ const nested = node.querySelectorAll?.(
185
+ 'script[type="application/json"][data-fict-snapshot]',
186
+ )
187
+ if (nested && nested.length) {
188
+ for (const script of Array.from(nested)) {
189
+ parseSnapshotScript(script as HTMLScriptElement)
190
+ }
191
+ }
192
+ }
193
+ }
194
+ })
195
+ snapshotObserver.observe(doc.documentElement ?? doc, { childList: true, subtree: true })
196
+ }
197
+
157
198
  __fictEnableResumable()
158
199
 
159
200
  const events = options.events ?? Array.from(DelegatedEvents)
@@ -174,6 +215,23 @@ export function installResumableLoader(options: ResumableLoaderOptions = {}): vo
174
215
  }
175
216
  }
176
217
 
218
+ function isSnapshotScript(script: HTMLScriptElement): boolean {
219
+ return script.type === 'application/json' && script.hasAttribute('data-fict-snapshot')
220
+ }
221
+
222
+ function parseSnapshotScript(script: HTMLScriptElement): void {
223
+ if (processedSnapshots.has(script)) return
224
+ processedSnapshots.add(script)
225
+ const text = script.textContent
226
+ if (!text) return
227
+ try {
228
+ const state = JSON.parse(text)
229
+ __fictMergeSSRState(state)
230
+ } catch {
231
+ // Ignore parse errors
232
+ }
233
+ }
234
+
177
235
  // ============================================================================
178
236
  // Prefetch Implementation
179
237
  // ============================================================================
package/src/resume.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { HookContext } from './hooks'
2
2
  import { createSignal, isSignal } from './signal'
3
+ import { __fictGetCurrentSSRBoundary } from './ssr-stream'
3
4
  import { createStore, isStoreProxy, unwrapStore } from './store'
4
5
 
5
6
  // ============================================================================
@@ -43,6 +44,7 @@ export interface ScopeRecord {
43
44
  id: string
44
45
  ctx: HookContext
45
46
  host: Element
47
+ boundaryId?: string
46
48
  type?: string
47
49
  props?: Record<string, unknown>
48
50
  }
@@ -52,6 +54,7 @@ let resumableEnabled = false
52
54
  let hydrating = false
53
55
  let scopeCounter = 0
54
56
  let scopeRegistry = new Map<string, ScopeRecord>()
57
+ let boundaryScopes = new Map<string, Set<string>>()
55
58
  let snapshotState: SSRState | null = null
56
59
  const resumedScopes = new Map<
57
60
  string,
@@ -62,12 +65,14 @@ export function __fictEnableSSR(): void {
62
65
  ssrEnabled = true
63
66
  scopeCounter = 0
64
67
  scopeRegistry = new Map()
68
+ boundaryScopes = new Map()
65
69
  resumedScopes.clear()
66
70
  snapshotState = null
67
71
  }
68
72
 
69
73
  export function __fictDisableSSR(): void {
70
74
  ssrEnabled = false
75
+ boundaryScopes = new Map()
71
76
  }
72
77
 
73
78
  export function __fictEnableResumable(): void {
@@ -124,6 +129,16 @@ export function __fictRegisterScope(
124
129
  if (props !== undefined) {
125
130
  record.props = props
126
131
  }
132
+ const boundaryId = __fictGetCurrentSSRBoundary()
133
+ if (boundaryId) {
134
+ record.boundaryId = boundaryId
135
+ let scopes = boundaryScopes.get(boundaryId)
136
+ if (!scopes) {
137
+ scopes = new Set()
138
+ boundaryScopes.set(boundaryId, scopes)
139
+ }
140
+ scopes.add(id)
141
+ }
127
142
  scopeRegistry.set(id, record)
128
143
  return id
129
144
  }
@@ -132,6 +147,12 @@ export function __fictGetScopeRegistry(): Map<string, ScopeRecord> {
132
147
  return scopeRegistry
133
148
  }
134
149
 
150
+ export function __fictGetScopesForBoundary(boundaryId: string): string[] {
151
+ const scopes = boundaryScopes.get(boundaryId)
152
+ if (!scopes) return []
153
+ return Array.from(scopes)
154
+ }
155
+
135
156
  export function __fictSerializeSSRState(): SSRState {
136
157
  const scopes: Record<string, ScopeSnapshot> = {}
137
158
 
@@ -155,6 +176,31 @@ export function __fictSerializeSSRState(): SSRState {
155
176
  return { scopes }
156
177
  }
157
178
 
179
+ export function __fictSerializeSSRStateForScopes(scopeIds: Iterable<string>): SSRState {
180
+ const scopes: Record<string, ScopeSnapshot> = {}
181
+
182
+ for (const id of scopeIds) {
183
+ const record = scopeRegistry.get(id)
184
+ if (!record) continue
185
+ const snapshot: ScopeSnapshot = {
186
+ id,
187
+ slots: serializeSlots(record.ctx),
188
+ }
189
+ if (record.type !== undefined) {
190
+ snapshot.t = record.type
191
+ }
192
+ if (record.props !== undefined) {
193
+ snapshot.props = record.props
194
+ }
195
+ if (record.ctx.slotMap !== undefined) {
196
+ snapshot.vars = record.ctx.slotMap
197
+ }
198
+ scopes[id] = snapshot
199
+ }
200
+
201
+ return { scopes }
202
+ }
203
+
158
204
  export function __fictSetSSRState(state: SSRState | null): void {
159
205
  snapshotState = state
160
206
  if (!state) {
@@ -162,6 +208,15 @@ export function __fictSetSSRState(state: SSRState | null): void {
162
208
  }
163
209
  }
164
210
 
211
+ export function __fictMergeSSRState(state: SSRState | null): void {
212
+ if (!state) return
213
+ if (!snapshotState) {
214
+ snapshotState = { scopes: { ...state.scopes } }
215
+ return
216
+ }
217
+ Object.assign(snapshotState.scopes, state.scopes)
218
+ }
219
+
165
220
  export function __fictGetSSRScope(id: string): ScopeSnapshot | undefined {
166
221
  return snapshotState?.scopes[id]
167
222
  }
package/src/signal.ts CHANGED
@@ -103,6 +103,10 @@ export interface SignalNode<T = unknown> extends BaseNode {
103
103
  currentValue: T
104
104
  /** Pending value to be committed */
105
105
  pendingValue: T
106
+ /** Previous committed value (for cleanup reads) */
107
+ prevValue?: T
108
+ /** Flush id when prevValue was recorded */
109
+ prevFlushId?: number
106
110
  /** Signals don't have dependencies */
107
111
  deps?: undefined
108
112
  depsTail?: undefined
@@ -123,6 +127,10 @@ export interface SignalNode<T = unknown> extends BaseNode {
123
127
  export interface ComputedNode<T = unknown> extends BaseNode {
124
128
  /** Current computed value */
125
129
  value: T
130
+ /** Previous computed value (for cleanup reads) */
131
+ prevValue?: T
132
+ /** Flush id when prevValue was recorded */
133
+ prevFlushId?: number
126
134
  /** First dependency link */
127
135
  deps: Link | undefined
128
136
  /** Last dependency link */
@@ -251,6 +259,8 @@ let cycle = 0
251
259
  let batchDepth = 0
252
260
  let activeSub: ReactiveNode | undefined
253
261
  let flushScheduled = false
262
+ let currentFlushId = 0
263
+ let activeCleanupFlushId = 0
254
264
  // Dual-priority queue for scheduler
255
265
  const highPriorityQueue: EffectNode[] = []
256
266
  const lowPriorityQueue: EffectNode[] = []
@@ -798,6 +808,8 @@ function updateSignal(s: SignalNode): boolean {
798
808
  const current = s.currentValue
799
809
  const pending = s.pendingValue
800
810
  if (valuesDiffer(s, current, pending)) {
811
+ s.prevValue = current
812
+ s.prevFlushId = currentFlushId
801
813
  s.currentValue = pending
802
814
  return true
803
815
  }
@@ -822,6 +834,8 @@ function updateComputed<T>(c: ComputedNode<T>): boolean {
822
834
  c.flags &= ~Running
823
835
  purgeDeps(c)
824
836
  if (valuesDiffer(c, oldValue, newValue)) {
837
+ c.prevValue = oldValue
838
+ c.prevFlushId = currentFlushId
825
839
  c.value = newValue
826
840
  if (isDev) updateComputedDevtools(c, newValue)
827
841
  return true
@@ -839,16 +853,20 @@ function updateComputed<T>(c: ComputedNode<T>): boolean {
839
853
  */
840
854
  function runEffect(e: EffectNode): void {
841
855
  const flags = e.flags
842
- // Run cleanup BEFORE checkDirty so cleanup sees previous signal values
843
- if (flags & Dirty) {
844
- if (e.runCleanup) {
845
- inCleanup = true
846
- try {
847
- e.runCleanup()
848
- } finally {
849
- inCleanup = false
850
- }
856
+ const runCleanup = () => {
857
+ if (!e.runCleanup) return
858
+ inCleanup = true
859
+ activeCleanupFlushId = currentFlushId
860
+ try {
861
+ e.runCleanup()
862
+ } finally {
863
+ activeCleanupFlushId = 0
864
+ inCleanup = false
851
865
  }
866
+ }
867
+ if (flags & Dirty) {
868
+ // Run cleanup before re-run; values are still the previous commit.
869
+ runCleanup()
852
870
  ++cycle
853
871
  if (isDev) effectRunDevtools(e)
854
872
  e.depsTail = undefined
@@ -866,15 +884,6 @@ function runEffect(e: EffectNode): void {
866
884
  throw err
867
885
  }
868
886
  } else if (flags & Pending && e.deps) {
869
- // Run cleanup before checkDirty which commits signal values
870
- if (e.runCleanup) {
871
- inCleanup = true
872
- try {
873
- e.runCleanup()
874
- } finally {
875
- inCleanup = false
876
- }
877
- }
878
887
  let isDirty = false
879
888
  try {
880
889
  isDirty = checkDirty(e.deps, e)
@@ -894,6 +903,9 @@ function runEffect(e: EffectNode): void {
894
903
  throw err
895
904
  }
896
905
  if (isDirty) {
906
+ // Only run cleanup if the effect will actually re-run.
907
+ // Cleanup reads should observe previous values for this flush.
908
+ runCleanup()
897
909
  ++cycle
898
910
  if (isDev) effectRunDevtools(e)
899
911
  e.depsTail = undefined
@@ -947,6 +959,7 @@ function flush(): void {
947
959
  endFlushGuard()
948
960
  return
949
961
  }
962
+ currentFlushId++
950
963
  flushScheduled = false
951
964
 
952
965
  // 1. Process all high-priority effects first
@@ -1071,6 +1084,12 @@ function signalOper<T>(this: SignalNode<T>, value?: T): T | void {
1071
1084
  if (subs !== undefined) shallowPropagate(subs)
1072
1085
  }
1073
1086
  }
1087
+ if (inCleanup) {
1088
+ if (this.prevFlushId === activeCleanupFlushId) {
1089
+ return this.prevValue as T
1090
+ }
1091
+ return this.currentValue
1092
+ }
1074
1093
 
1075
1094
  let sub = activeSub
1076
1095
  while (sub !== undefined) {
@@ -1118,10 +1137,14 @@ export function computed<T>(
1118
1137
  return bound as ComputedAccessor<T>
1119
1138
  }
1120
1139
  function computedOper<T>(this: ComputedNode<T>): T {
1121
- // fix: During cleanup, return cached value without triggering any updates.
1122
- // This ensures cleanup functions see the previous state, not the new pending values.
1123
- // Without this check, checkDirty() could commit pending signal values during cleanup.
1124
- if (inCleanup) return this.value
1140
+ // fix: During cleanup, return previous value for this flush without triggering updates.
1141
+ // This ensures cleanup functions observe the pre-commit state for this effect.
1142
+ if (inCleanup) {
1143
+ if (this.prevFlushId === activeCleanupFlushId) {
1144
+ return this.prevValue as T
1145
+ }
1146
+ return this.value
1147
+ }
1125
1148
 
1126
1149
  const flags = this.flags
1127
1150
 
@@ -1388,6 +1411,8 @@ export function __resetReactiveState(): void {
1388
1411
  isInTransition = false
1389
1412
  inCleanup = false
1390
1413
  cycle = 0
1414
+ currentFlushId = 0
1415
+ activeCleanupFlushId = 0
1391
1416
  }
1392
1417
  /**
1393
1418
  * Execute a function without tracking dependencies
@@ -0,0 +1,38 @@
1
+ export interface SSRStreamHooks {
2
+ registerBoundary?: (start: Comment, end: Comment) => string | null
3
+ boundaryPending?: (id: string) => void
4
+ boundaryResolved?: (id: string) => void
5
+ onError?: (error: unknown, boundaryId?: string) => void
6
+ }
7
+
8
+ let ssrStreamHooks: SSRStreamHooks | null = null
9
+ const boundaryStack: string[] = []
10
+
11
+ export function __fictSetSSRStreamHooks(hooks: SSRStreamHooks | null): void {
12
+ ssrStreamHooks = hooks
13
+ if (!hooks) {
14
+ boundaryStack.length = 0
15
+ }
16
+ }
17
+
18
+ export function __fictGetSSRStreamHooks(): SSRStreamHooks | null {
19
+ return ssrStreamHooks
20
+ }
21
+
22
+ export function __fictPushSSRBoundary(id: string): void {
23
+ boundaryStack.push(id)
24
+ }
25
+
26
+ export function __fictPopSSRBoundary(expected?: string): void {
27
+ if (boundaryStack.length === 0) return
28
+ const top = boundaryStack[boundaryStack.length - 1]
29
+ if (expected && top !== expected) {
30
+ boundaryStack.pop()
31
+ return
32
+ }
33
+ boundaryStack.pop()
34
+ }
35
+
36
+ export function __fictGetCurrentSSRBoundary(): string | null {
37
+ return boundaryStack.length > 0 ? boundaryStack[boundaryStack.length - 1]! : null
38
+ }
package/src/suspense.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  } from './lifecycle'
13
13
  import { insertNodesBefore, removeNodes, toNodeArray } from './node-ops'
14
14
  import { createSignal } from './signal'
15
+ import { __fictGetSSRStreamHooks, __fictPopSSRBoundary, __fictPushSSRBoundary } from './ssr-stream'
15
16
  import type { BaseProps, FictNode, SuspenseToken } from './types'
16
17
 
17
18
  export interface SuspenseProps extends BaseProps {
@@ -49,6 +50,7 @@ const isThenable = (value: unknown): value is PromiseLike<unknown> =>
49
50
  typeof (value as PromiseLike<unknown>).then === 'function'
50
51
 
51
52
  export function Suspense(props: SuspenseProps): FictNode {
53
+ const streamHooks = __fictGetSSRStreamHooks()
52
54
  const pending = createSignal(0)
53
55
  let resolvedOnce = false
54
56
  let epoch = 0
@@ -76,7 +78,12 @@ export function Suspense(props: SuspenseProps): FictNode {
76
78
  const root = createRootContext(hostRoot)
77
79
  const prev = pushRoot(root)
78
80
  let nodes: Node[] = []
81
+ let boundaryPushed = false
79
82
  try {
83
+ if (streamBoundaryId) {
84
+ __fictPushSSRBoundary(streamBoundaryId)
85
+ boundaryPushed = true
86
+ }
80
87
  const output = createElement(view)
81
88
  nodes = toNodeArray(output)
82
89
  // Suspended view: child threw a suspense token and was handled upstream.
@@ -90,9 +97,9 @@ export function Suspense(props: SuspenseProps): FictNode {
90
97
  destroyRoot(root)
91
98
  return
92
99
  }
93
- const parentNode = marker.parentNode as (ParentNode & Node) | null
100
+ const parentNode = endMarker.parentNode as (ParentNode & Node) | null
94
101
  if (parentNode) {
95
- insertNodesBefore(parentNode, nodes, marker)
102
+ insertNodesBefore(parentNode, nodes, endMarker)
96
103
  }
97
104
  } catch (err) {
98
105
  popRoot(prev)
@@ -101,6 +108,10 @@ export function Suspense(props: SuspenseProps): FictNode {
101
108
  throw err
102
109
  }
103
110
  return
111
+ } finally {
112
+ if (boundaryPushed) {
113
+ __fictPopSSRBoundary(streamBoundaryId ?? undefined)
114
+ }
104
115
  }
105
116
  popRoot(prev)
106
117
  flushOnMount(root)
@@ -113,10 +124,22 @@ export function Suspense(props: SuspenseProps): FictNode {
113
124
  }
114
125
 
115
126
  const fragment = document.createDocumentFragment()
116
- const marker = document.createComment('fict:suspense')
117
- fragment.appendChild(marker)
127
+ const startMarker = document.createComment('fict:suspense-start')
128
+ const endMarker = document.createComment('fict:suspense-end')
129
+ fragment.appendChild(startMarker)
130
+ fragment.appendChild(endMarker)
118
131
  let cleanup: (() => void) | undefined
119
132
  let activeNodes: Node[] = []
133
+ let streamBoundaryId: string | null = null
134
+ let streamPending = false
135
+
136
+ if (streamHooks?.registerBoundary) {
137
+ streamBoundaryId = streamHooks.registerBoundary(startMarker, endMarker) ?? null
138
+ if (streamBoundaryId) {
139
+ startMarker.data = `fict:suspense-start:${streamBoundaryId}`
140
+ endMarker.data = `fict:suspense-end:${streamBoundaryId}`
141
+ }
142
+ }
120
143
 
121
144
  const onResolveMaybe = () => {
122
145
  if (!resolvedOnce) {
@@ -127,6 +150,10 @@ export function Suspense(props: SuspenseProps): FictNode {
127
150
 
128
151
  registerSuspenseHandler(token => {
129
152
  const tokenEpoch = epoch
153
+ if (!streamPending && streamBoundaryId && streamHooks?.boundaryPending) {
154
+ streamPending = true
155
+ streamHooks.boundaryPending(streamBoundaryId)
156
+ }
130
157
  pending(pending() + 1)
131
158
  // Directly render fallback instead of using switchView to avoid
132
159
  // triggering the effect which would cause duplicate renders
@@ -155,6 +182,10 @@ export function Suspense(props: SuspenseProps): FictNode {
155
182
  if (newPending === 0) {
156
183
  // Directly render children instead of using switchView
157
184
  renderView(props.children ?? null)
185
+ if (streamPending && streamBoundaryId && streamHooks?.boundaryResolved) {
186
+ streamPending = false
187
+ streamHooks.boundaryResolved(streamBoundaryId)
188
+ }
158
189
  onResolveMaybe()
159
190
  }
160
191
  },
@@ -165,9 +196,29 @@ export function Suspense(props: SuspenseProps): FictNode {
165
196
  }
166
197
  const newPending = Math.max(0, pending() - 1)
167
198
  pending(newPending)
168
- props.onReject?.(err)
169
- if (!handleError(err, { source: 'render' }, hostRoot)) {
170
- throw err
199
+ let rejectionError = err
200
+ try {
201
+ props.onReject?.(err)
202
+ } catch (callbackError) {
203
+ rejectionError = callbackError
204
+ }
205
+
206
+ const handled = handleError(rejectionError, { source: 'render' }, hostRoot)
207
+ if (!handled) {
208
+ if (streamHooks?.onError) {
209
+ streamHooks.onError(rejectionError, streamBoundaryId ?? undefined)
210
+ return
211
+ }
212
+ throw rejectionError
213
+ }
214
+ if (
215
+ newPending === 0 &&
216
+ streamPending &&
217
+ streamBoundaryId &&
218
+ streamHooks?.boundaryResolved
219
+ ) {
220
+ streamPending = false
221
+ streamHooks.boundaryResolved(streamBoundaryId)
171
222
  }
172
223
  },
173
224
  )
@@ -195,6 +246,10 @@ export function Suspense(props: SuspenseProps): FictNode {
195
246
  pending(0)
196
247
  // Directly render children instead of using switchView
197
248
  renderView(props.children ?? null)
249
+ if (streamPending && streamBoundaryId && streamHooks?.boundaryResolved) {
250
+ streamPending = false
251
+ streamHooks.boundaryResolved(streamBoundaryId)
252
+ }
198
253
  }
199
254
  })
200
255
  }