@statorjs/stator 1.3.0 → 1.4.1

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.
@@ -4,6 +4,7 @@ import {
4
4
  registerBinding,
5
5
  requireCurrentRenderState,
6
6
  } from '../../server/render-context.ts'
7
+ import { isItemReadResult } from '../each.ts'
7
8
  import { isReadResult, type ReadResult } from '../read.ts'
8
9
  import { type DirectiveInvocation, defineDirective, invoke } from './core.ts'
9
10
 
@@ -20,6 +21,15 @@ import { type DirectiveInvocation, defineDirective, invoke } from './core.ts'
20
21
 
21
22
  type ConditionalEntry<TValue> = TValue | ReadResult<TValue>
22
23
 
24
+ /** Resolve the machine instance a spec read evaluates against. During a
25
+ * fan-out recompute this returns the CURRENT proxy — the ReadResult's captured
26
+ * `.instance` was frozen at first render, and `rehydrate()` has since replaced
27
+ * the connection's actor (FINDINGS #3, one composition boundary further out:
28
+ * the spec object outlives the render that built it). Null runtime (initial
29
+ * render, request-scoped recompute) falls back to the captured instance,
30
+ * which is current there by construction. */
31
+ type InstanceResolver = (r: ReadResult<unknown>) => unknown
32
+
23
33
  /** Recursive shape accepted by class:list. */
24
34
  export type ClassListSpec =
25
35
  | string
@@ -40,11 +50,25 @@ export type StyleListSpec =
40
50
  | Record<string, ConditionalEntry<string | number | null | undefined>>
41
51
  | StyleListSpec[]
42
52
 
43
- function evalRead<T>(value: ConditionalEntry<T>): T {
44
- return isReadResult(value) ? (value.selector(value.instance) as T) : value
53
+ function evalRead<T>(value: ConditionalEntry<T>, resolve: InstanceResolver): T {
54
+ return isReadResult(value) ? (value.selector(resolve(value)) as T) : value
55
+ }
56
+
57
+ /** Backstop for hand-written templates (the compiler rejects this at build
58
+ * time): an item read inside a `:list` spec would stringify as garbage AND
59
+ * never re-diff — the compound directive recomposes per machine, not per row. */
60
+ function rejectItemRead(v: unknown): void {
61
+ if (isItemReadResult(v)) {
62
+ throw new Error(
63
+ 'stator: read(item, …) is not supported inside a class:list/style:list spec — the ' +
64
+ 'compound directive recomposes per machine, not per row. Give the whole attribute ' +
65
+ 'a single item read, or use a machine read inside the spec.',
66
+ )
67
+ }
45
68
  }
46
69
 
47
70
  function collectMachines(spec: unknown, out: Set<string>): void {
71
+ rejectItemRead(spec)
48
72
  if (spec == null || spec === false) return
49
73
  if (typeof spec === 'string') return
50
74
  if (isReadResult(spec)) {
@@ -57,47 +81,48 @@ function collectMachines(spec: unknown, out: Set<string>): void {
57
81
  }
58
82
  if (typeof spec === 'object') {
59
83
  for (const v of Object.values(spec)) {
84
+ rejectItemRead(v)
60
85
  if (isReadResult(v)) out.add(v.machineName)
61
86
  }
62
87
  }
63
88
  }
64
89
 
65
- function composeClass(spec: ClassListSpec): string {
90
+ function composeClass(spec: ClassListSpec, resolve: InstanceResolver): string {
66
91
  const parts: string[] = []
67
- walkClass(spec, parts)
92
+ walkClass(spec, parts, resolve)
68
93
  return parts.join(' ')
69
94
  }
70
95
 
71
- function walkClass(spec: ClassListSpec, out: string[]): void {
96
+ function walkClass(spec: ClassListSpec, out: string[], resolve: InstanceResolver): void {
72
97
  if (spec == null || spec === false) return
73
98
  if (typeof spec === 'string') {
74
99
  if (spec.length > 0) out.push(spec)
75
100
  return
76
101
  }
77
102
  if (isReadResult(spec)) {
78
- const v = spec.selector(spec.instance)
103
+ const v = spec.selector(resolve(spec))
79
104
  if (typeof v === 'string' && v.length > 0) out.push(v)
80
105
  return
81
106
  }
82
107
  if (Array.isArray(spec)) {
83
- for (const item of spec) walkClass(item, out)
108
+ for (const item of spec) walkClass(item, out, resolve)
84
109
  return
85
110
  }
86
111
  if (typeof spec === 'object') {
87
112
  for (const [name, cond] of Object.entries(spec)) {
88
- const truthy = evalRead(cond)
113
+ const truthy = evalRead(cond, resolve)
89
114
  if (truthy) out.push(name)
90
115
  }
91
116
  }
92
117
  }
93
118
 
94
- function composeStyle(spec: StyleListSpec): string {
119
+ function composeStyle(spec: StyleListSpec, resolve: InstanceResolver): string {
95
120
  const decls: string[] = []
96
- walkStyle(spec, decls)
121
+ walkStyle(spec, decls, resolve)
97
122
  return decls.join('; ')
98
123
  }
99
124
 
100
- function walkStyle(spec: StyleListSpec, out: string[]): void {
125
+ function walkStyle(spec: StyleListSpec, out: string[], resolve: InstanceResolver): void {
101
126
  if (spec == null || spec === false) return
102
127
  if (typeof spec === 'string') {
103
128
  const trimmed = spec.trim()
@@ -105,19 +130,19 @@ function walkStyle(spec: StyleListSpec, out: string[]): void {
105
130
  return
106
131
  }
107
132
  if (isReadResult(spec)) {
108
- const v = spec.selector(spec.instance)
133
+ const v = spec.selector(resolve(spec))
109
134
  if (typeof v === 'string' && v.trim().length > 0) {
110
135
  out.push(v.trim().replace(/;\s*$/, ''))
111
136
  }
112
137
  return
113
138
  }
114
139
  if (Array.isArray(spec)) {
115
- for (const item of spec) walkStyle(item, out)
140
+ for (const item of spec) walkStyle(item, out, resolve)
116
141
  return
117
142
  }
118
143
  if (typeof spec === 'object') {
119
144
  for (const [prop, raw] of Object.entries(spec)) {
120
- const value = evalRead(raw)
145
+ const value = evalRead(raw, resolve)
121
146
  if (value == null || value === '') continue
122
147
  // A single property's value must not carry `;` — a `read()`-sourced value
123
148
  // like `red; position: fixed; …` would otherwise inject extra
@@ -140,26 +165,32 @@ function cssValue(v: string): string {
140
165
  /**
141
166
  * Register one attr-binding per unique machine the spec depends on. Each
142
167
  * binding's selector ignores its arg and recomputes the entire attribute
143
- * by re-walking the spec — every ReadResult inside re-evaluates its
144
- * `.selector(.instance)`, returning fresh state.
168
+ * by re-walking the spec — every ReadResult inside re-evaluates its selector
169
+ * against the RESOLVED instance (current proxy under a fan-out recompute,
170
+ * the captured one otherwise), returning fresh state.
145
171
  */
146
172
  function bindListAttr<TSpec>(
147
173
  state: RenderState,
148
174
  spec: TSpec,
149
175
  attrName: string,
150
176
  elementId: string,
151
- compose: (spec: TSpec) => string,
177
+ compose: (spec: TSpec, resolve: InstanceResolver) => string,
152
178
  ): string {
153
179
  const machines = new Set<string>()
154
180
  collectMachines(spec, machines)
155
181
 
156
- const initial = compose(spec)
182
+ // Resolution is per-evaluation, through the render state the binding was
183
+ // registered under — recompute sets `currentRuntime` for the fan-out pass.
184
+ const resolve: InstanceResolver = (r) =>
185
+ state.currentRuntime?.proxyFor(r.machineName) ?? r.instance
186
+
187
+ const initial = compose(spec, resolve)
157
188
  for (const machineName of machines) {
158
189
  const slotId = allocSlotId(state)
159
190
  registerBinding(state, {
160
191
  slotId,
161
192
  machineName,
162
- selector: () => compose(spec),
193
+ selector: () => compose(spec, resolve),
163
194
  lastValue: initial,
164
195
  kind: 'attr',
165
196
  attrName,
@@ -3,6 +3,7 @@ import {
3
3
  type ErasedItemRenderer,
4
4
  type ErasedKeyFn,
5
5
  type ErasedSelector,
6
+ type ItemBinding,
6
7
  keyedScopePrefix,
7
8
  keyToken,
8
9
  popListScope,
@@ -37,6 +38,42 @@ export function isEachResult(v: unknown): v is EachResult {
37
38
  )
38
39
  }
39
40
 
41
+ /**
42
+ * SPIKE (finding #5 / option C): the value of an item-dependent interpolation
43
+ * inside an `each` row — what the compiler will lower `{item.field}` to. It
44
+ * evaluates the selector against the *current* row's item now, and registers a
45
+ * per-row binding so a later content change patches this one slot in place
46
+ * (rather than re-rendering the row, so islands/focus survive).
47
+ */
48
+ export interface ItemReadResult {
49
+ readonly __isItemRead: true
50
+ readonly selector: (item: unknown, index: number) => unknown
51
+ readonly value: unknown
52
+ }
53
+
54
+ export function isItemReadResult(v: unknown): v is ItemReadResult {
55
+ return typeof v === 'object' && v !== null && (v as Record<string, unknown>).__isItemRead === true
56
+ }
57
+
58
+ // biome-ignore lint/suspicious/noExplicitAny: erased item selector — the item type is the each callback's, recovered at the call site
59
+ export function itemBind(selector: (item: any, index: number) => unknown): ItemReadResult {
60
+ const state = requireCurrentRenderState()
61
+ if (!state.currentRowBindings) {
62
+ throw new Error(
63
+ 'stator: read(item, …) (itemBind) called outside an each() row render — an item ' +
64
+ 'binding is owned by its row, which supplies the item and re-diffs the binding. ' +
65
+ 'This happens when an item read sits inside a when()/match()/defer() arm (an arm ' +
66
+ 're-renders on its own schedule, without the row) or outside each() entirely. ' +
67
+ 'Use a machine read there instead.',
68
+ )
69
+ }
70
+ // Compute the value now (for initial render); the per-row binding is registered
71
+ // by html.ts, which knows the position (text span vs attribute) — same split as
72
+ // read() → handleRead.
73
+ const value = selector(state.currentItem, state.currentItemIndex ?? 0)
74
+ return { __isItemRead: true, selector, value }
75
+ }
76
+
40
77
  export function each<T>(
41
78
  items: readonly T[] | ReadResult<readonly T[]>,
42
79
  fn: (item: T, index: number) => HtmlFragment,
@@ -63,8 +100,10 @@ export function each<T>(
63
100
  let innerHtml: string
64
101
  if (keyFn) {
65
102
  const keys = coerceKeys(array, keyFn as ErasedKeyFn, slotId)
66
- innerHtml = renderKeyedListBody(state, slotId, array, keys, fn)
103
+ const body = renderKeyedListBody(state, slotId, array, keys, fn)
104
+ innerHtml = body.html
67
105
  if (machineName && selector) {
106
+ const hasItemBindings = [...body.rowsByKey.values()].some((b) => b.length > 0)
68
107
  registerBinding(state, {
69
108
  slotId,
70
109
  machineName,
@@ -74,10 +113,13 @@ export function each<T>(
74
113
  itemRenderer: fn as ErasedItemRenderer,
75
114
  keyFn: keyFn as ErasedKeyFn,
76
115
  lastKeys: keys,
116
+ // Same opt-in as non-keyed: only track rows when there ARE item bindings.
117
+ rowsByKey: hasItemBindings ? body.rowsByKey : undefined,
77
118
  })
78
119
  }
79
120
  } else {
80
- innerHtml = renderListBody(state, slotId, array, fn)
121
+ const body = renderListBody(state, slotId, array, fn)
122
+ innerHtml = body.html
81
123
  if (machineName && selector) {
82
124
  registerBinding(state, {
83
125
  slotId,
@@ -86,6 +128,10 @@ export function each<T>(
86
128
  lastValue: array,
87
129
  kind: 'list',
88
130
  itemRenderer: fn as ErasedItemRenderer,
131
+ // Only opt into the granular path when there ARE item bindings —
132
+ // otherwise (static-capture rows) leave it undefined so recompute
133
+ // wholesale-re-renders as before, keeping content fresh.
134
+ rows: body.rows.some((r) => r.length > 0) ? body.rows : undefined,
89
135
  })
90
136
  }
91
137
  }
@@ -111,23 +157,39 @@ export function renderListBody<T>(
111
157
  listSlotId: SlotId,
112
158
  items: readonly T[],
113
159
  fn: (item: T, index: number) => HtmlFragment,
114
- ): string {
160
+ ): { html: string; rows: ItemBinding[][] } {
115
161
  unregisterBindingsForScope(state, listSlotId)
116
162
 
163
+ // Save/restore the ambient row context around the whole body so a nested
164
+ // each() (a row rendering its own list) doesn't clobber this list's row.
165
+ const prevItem = state.currentItem
166
+ const prevIndex = state.currentItemIndex
167
+ const prevRow = state.currentRowBindings
168
+
117
169
  const chunks: string[] = []
170
+ const rows: ItemBinding[][] = []
118
171
  for (let i = 0; i < items.length; i++) {
119
172
  pushListScope(state, listSlotId, i)
173
+ const rowBindings: ItemBinding[] = []
174
+ state.currentItem = items[i]
175
+ state.currentItemIndex = i
176
+ state.currentRowBindings = rowBindings
120
177
  try {
121
178
  const fragment = fn(items[i]!, i)
122
179
  if (!isHtmlFragment(fragment)) {
123
180
  throw new Error('stator: each() callback must return an html`...` result')
124
181
  }
125
182
  chunks.push(fragment.html)
183
+ rows.push(rowBindings)
126
184
  } finally {
127
185
  popListScope(state)
128
186
  }
129
187
  }
130
- return chunks.join('')
188
+
189
+ state.currentItem = prevItem
190
+ state.currentItemIndex = prevIndex
191
+ state.currentRowBindings = prevRow
192
+ return { html: chunks.join(''), rows }
131
193
  }
132
194
 
133
195
  /**
@@ -173,13 +235,17 @@ export function renderKeyedListBody<T>(
173
235
  items: readonly T[],
174
236
  keys: readonly string[],
175
237
  fn: (item: T, index: number) => HtmlFragment,
176
- ): string {
238
+ ): { html: string; rowsByKey: Map<string, ItemBinding[]> } {
177
239
  unregisterBindingsForScope(state, listSlotId)
178
240
  const chunks: string[] = []
241
+ const rowsByKey = new Map<string, ItemBinding[]>()
179
242
  for (let i = 0; i < items.length; i++) {
180
- chunks.push(renderKeyedItem(state, listSlotId, items[i] as T, i, keys[i]!, fn))
243
+ const key = keys[i]!
244
+ const row = renderKeyedItem(state, listSlotId, items[i] as T, i, key, fn)
245
+ chunks.push(row.html)
246
+ rowsByKey.set(key, row.bindings)
181
247
  }
182
- return chunks.join('')
248
+ return { html: chunks.join(''), rowsByKey }
183
249
  }
184
250
 
185
251
  /**
@@ -195,10 +261,21 @@ export function renderKeyedItem<T>(
195
261
  index: number,
196
262
  key: string,
197
263
  fn: (item: T, index: number) => HtmlFragment,
198
- ): string {
264
+ ): { html: string; bindings: ItemBinding[] } {
199
265
  const token = keyToken(key)
200
266
  unregisterBindingsForScope(state, keyedScopePrefix(listSlotId, token))
201
267
  pushKeyedScope(state, listSlotId, token)
268
+
269
+ // Item-value binding context for this row (option C, keyed), save/restore
270
+ // around the body so a nested each() doesn't clobber it.
271
+ const prevItem = state.currentItem
272
+ const prevIndex = state.currentItemIndex
273
+ const prevRow = state.currentRowBindings
274
+ const bindings: ItemBinding[] = []
275
+ state.currentItem = item
276
+ state.currentItemIndex = index
277
+ state.currentRowBindings = bindings
278
+
202
279
  let html: string
203
280
  try {
204
281
  const fragment = fn(item, index)
@@ -207,6 +284,9 @@ export function renderKeyedItem<T>(
207
284
  }
208
285
  html = fragment.html
209
286
  } finally {
287
+ state.currentItem = prevItem
288
+ state.currentItemIndex = prevIndex
289
+ state.currentRowBindings = prevRow
210
290
  popListScope(state)
211
291
  }
212
292
  if (!isSingleRootElement(html)) {
@@ -216,7 +296,7 @@ export function renderKeyedItem<T>(
216
296
  `items would corrupt sibling indices`,
217
297
  )
218
298
  }
219
- return html
299
+ return { html, bindings }
220
300
  }
221
301
 
222
302
  const VOID_TAGS = new Set([
@@ -1,4 +1,5 @@
1
1
  import {
2
+ allocSlotId,
2
3
  type RenderState,
3
4
  registerBinding,
4
5
  requireCurrentRenderState,
@@ -22,12 +23,13 @@ export function raw(html: string): HtmlFragment {
22
23
  }
23
24
 
24
25
  import { isBranchResult } from './conditional.ts'
26
+ import { isDeferResult } from './defer.ts'
25
27
  import {
26
28
  type DirectiveContext,
27
29
  type DirectiveInvocation,
28
30
  isDirectiveInvocation,
29
31
  } from './directives/core.ts'
30
- import { isEachResult } from './each.ts'
32
+ import { type ItemReadResult, isEachResult, isItemReadResult } from './each.ts'
31
33
  import { isReadResult, type ReadResult } from './read.ts'
32
34
 
33
35
  export function html(strings: TemplateStringsArray, ...values: unknown[]): HtmlFragment {
@@ -100,11 +102,26 @@ function processValue(builder: HtmlBuilder, state: RenderState, value: unknown):
100
102
  return
101
103
  }
102
104
 
105
+ if (isDeferResult(value)) {
106
+ if (pos.kind !== 'text') {
107
+ throw new Error('stator: cannot inline a defer() result outside text position')
108
+ }
109
+ builder.pushRaw(value.html)
110
+ return
111
+ }
112
+
103
113
  if (isReadResult(value)) {
104
114
  handleRead(builder, state, value, pos)
105
115
  return
106
116
  }
107
117
 
118
+ // read(item, …) → itemBind: register the per-row binding by position (text span
119
+ // or attribute), the item analog of handleRead.
120
+ if (isItemReadResult(value)) {
121
+ handleItemRead(builder, state, value, pos)
122
+ return
123
+ }
124
+
108
125
  if (pos.kind === 'text') {
109
126
  builder.pushRaw(escapeText(stringifyValue(value)))
110
127
  return
@@ -180,6 +197,53 @@ function handleRead(
180
197
  throw new Error(`stator: read() result cannot be interpolated at ${pos.kind} position`)
181
198
  }
182
199
 
200
+ /** Register a `read(item, …)` per-row binding by position — the item analog of
201
+ * handleRead. Pushes onto the row's binding list (owned by the ListBinding), not
202
+ * state.bindings; recompute diffs it per row and emits text- or attr-op patches. */
203
+ function handleItemRead(
204
+ builder: HtmlBuilder,
205
+ state: RenderState,
206
+ r: ItemReadResult,
207
+ pos: ValuePosition,
208
+ ): void {
209
+ const row = state.currentRowBindings
210
+ if (!row) {
211
+ throw new Error(
212
+ 'stator: read(item, …) interpolated outside an each() row render — an item binding ' +
213
+ 'is owned by its row (see the itemBind ownership rule). Use a machine read here.',
214
+ )
215
+ }
216
+ if (pos.kind === 'text') {
217
+ const slotId = allocSlotId(state)
218
+ row.push({ kind: 'text', slotId, selector: r.selector, lastValue: r.value })
219
+ builder.pushRaw(`<span data-slot="${slotId}">${escapeText(stringifyValue(r.value))}</span>`)
220
+ return
221
+ }
222
+ if (pos.kind === 'attr-value') {
223
+ if (pos.hasLiteralText) {
224
+ throw new Error(
225
+ `stator: attribute "${pos.attrName}" mixes literal text with a read(item, …). ` +
226
+ `An attribute value must come from a single source.`,
227
+ )
228
+ }
229
+ row.push({
230
+ kind: 'attr',
231
+ attrName: pos.attrName,
232
+ parentId: pos.elementId,
233
+ selector: r.selector,
234
+ lastValue: r.value,
235
+ })
236
+ // Same boolean semantics as a machine attr read (see handleRead).
237
+ if (r.value === false || r.value === null || r.value === undefined) {
238
+ builder.omitCurrentAttribute()
239
+ } else if (r.value !== true) {
240
+ builder.pushRaw(escapeAttribute(sanitizeAttrValue(pos.attrName, stringifyValue(r.value))))
241
+ }
242
+ return
243
+ }
244
+ throw new Error(`stator: read(item, …) cannot be interpolated at ${pos.kind} position`)
245
+ }
246
+
183
247
  function stringifyValue(v: unknown): string {
184
248
  if (v == null) return ''
185
249
  if (typeof v === 'string') return v
@@ -6,6 +6,8 @@ export {
6
6
  renderBranchBody,
7
7
  when,
8
8
  } from './conditional.ts'
9
+ export type { DeferArms, DeferResult } from './defer.ts'
10
+ export { defer, isDeferResult } from './defer.ts'
9
11
  export type {
10
12
  Directive,
11
13
  DirectiveContext,
@@ -21,7 +23,7 @@ export type { ClassListSpec, StyleListSpec } from './directives/list-attr.ts'
21
23
  export { classList, styleList } from './directives/list-attr.ts'
22
24
  export { on } from './directives/on.ts'
23
25
  export type { EachResult } from './each.ts'
24
- export { each, isEachResult, renderListBody } from './each.ts'
26
+ export { each, isEachResult, itemBind, renderListBody } from './each.ts'
25
27
  export { html, raw } from './html.ts'
26
28
  export type { ReadResult } from './read.ts'
27
29
  export { isReadResult, read } from './read.ts'
@@ -29,22 +29,46 @@ export function isReadResult(v: unknown): v is ReadResult {
29
29
  export function read<TDef extends AnyMachineDef, TResult>(
30
30
  instance: InstanceOf<TDef>,
31
31
  selector: (instance: InstanceOf<TDef>) => TResult,
32
- ): ReadResult<TResult> {
32
+ ): ReadResult<TResult>
33
+ /**
34
+ * Live field of an `each` row — `read(item, (i) => i.field)`. `read()` is the
35
+ * one marker for live data, so an item field that changes over time is read the
36
+ * same way a machine value is. The compiler lowers this form to a per-row
37
+ * `itemBind`; it is valid only where `item` is the each callback's item param.
38
+ */
39
+ export function read<TItem, TResult>(
40
+ item: TItem,
41
+ selector: (item: TItem) => TResult,
42
+ ): ReadResult<TResult>
43
+ export function read(
44
+ // biome-ignore lint/suspicious/noExplicitAny: overload implementation — the typed surface is the two signatures above
45
+ instance: any,
46
+ // biome-ignore lint/suspicious/noExplicitAny: overload implementation — see above
47
+ selector: (instance: any) => unknown,
48
+ ): ReadResult {
33
49
  const state = requireCurrentRenderState()
34
50
  const def = defForProxy(instance as unknown as object)
35
51
  if (!def) {
52
+ // The item-read overload exists only for typing — the compiler rewrites
53
+ // read(item, …) to itemBind before runtime, so a non-machine here is a bug.
36
54
  throw new Error(
37
55
  'stator: read() must be called with a machine instance produced by the framework',
38
56
  )
39
57
  }
40
58
  const slotId = allocSlotId(state)
41
- const value = selector(instance)
59
+ // During a recompute-driven re-render (fan-out), resolve the CURRENT proxy for
60
+ // this machine so arm/list interiors see fresh state — the closure `instance`
61
+ // was frozen at connect-time by `rehydrate()` (FINDINGS #3). On the initial
62
+ // render `currentRuntime` is null and `instance` already IS the current proxy.
63
+ const live = state.currentRuntime?.proxyFor(def.name)
64
+ const source = live ?? instance
65
+ const value = selector(source)
42
66
  return {
43
67
  __isReadResult: true,
44
68
  slotId,
45
69
  machineName: def.name,
46
70
  selector: selector as ErasedSelector,
47
- instance,
48
- value: value as TResult,
71
+ instance: source,
72
+ value,
49
73
  }
50
74
  }
@@ -0,0 +1,70 @@
1
+ export type ResourceStatus = 'pending' | 'fulfilled' | 'rejected'
2
+
3
+ /**
4
+ * A peekable async value. You cannot synchronously read a native promise's
5
+ * settled state (`.then` is always a microtask), so `defer` wraps a thunk's
6
+ * result in a resource that records its settlement as it happens — letting the
7
+ * fill pass read `status` synchronously ("peek") at the moment it splices HTML.
8
+ *
9
+ * A non-promise thunk result fulfills immediately, so `defer` degrades to plain
10
+ * inline render for synchronous data.
11
+ */
12
+ export interface Resource<T = unknown> {
13
+ readonly status: ResourceStatus
14
+ readonly value?: T
15
+ readonly reason?: unknown
16
+ /**
17
+ * Resolves when the resource settles (fulfilled OR rejected). Never rejects —
18
+ * a rejection is recorded as `status: 'rejected'` + `reason`, and `settled`
19
+ * still resolves, so the resolve phase can `await` a batch of resources with a
20
+ * plain `Promise.all` and never has to catch.
21
+ */
22
+ readonly settled: Promise<void>
23
+ }
24
+
25
+ function isPromiseLike(v: unknown): v is PromiseLike<unknown> {
26
+ return (
27
+ (typeof v === 'object' || typeof v === 'function') &&
28
+ v !== null &&
29
+ typeof (v as { then?: unknown }).then === 'function'
30
+ )
31
+ }
32
+
33
+ /**
34
+ * Wrap a thunk's result in a peekable {@link Resource}. Synchronous values (and
35
+ * synchronous throws) settle immediately; a promise records its settlement on a
36
+ * single attached continuation.
37
+ */
38
+ export function createResource<T>(produce: () => T | Promise<T>): Resource<T> {
39
+ let result: T | Promise<T>
40
+ try {
41
+ result = produce()
42
+ } catch (reason) {
43
+ // A thunk that throws synchronously is a rejected resource, not a crash.
44
+ return { status: 'rejected', reason, settled: Promise.resolve() }
45
+ }
46
+
47
+ if (!isPromiseLike(result)) {
48
+ return { status: 'fulfilled', value: result, settled: Promise.resolve() }
49
+ }
50
+
51
+ const resource: {
52
+ status: ResourceStatus
53
+ value?: T
54
+ reason?: unknown
55
+ settled: Promise<void>
56
+ } = { status: 'pending', settled: Promise.resolve() }
57
+
58
+ resource.settled = Promise.resolve(result).then(
59
+ (value) => {
60
+ resource.status = 'fulfilled'
61
+ resource.value = value
62
+ },
63
+ (reason) => {
64
+ resource.status = 'rejected'
65
+ resource.reason = reason
66
+ },
67
+ )
68
+
69
+ return resource
70
+ }