@abide/abide 0.34.2 → 0.35.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,62 @@
1
+ import { constants, createGzip } from 'node:zlib'
2
+
3
+ /*
4
+ A gzip TransformStream that emits a decodable block after every input chunk via
5
+ Z_SYNC_FLUSH, instead of buffering until its deflate window fills like the web
6
+ CompressionStream. Used for the streamed SSR document so the head reaches the
7
+ browser compressed-but-decodable immediately — the preload scanner sees the
8
+ entry/css links and the pending shell paints — rather than only at stream close.
9
+ node:zlib is required here: the web CompressionStream exposes no per-chunk flush.
10
+ */
11
+ export function flushingGzipStream(): TransformStream<Uint8Array, Uint8Array> {
12
+ const gzip = createGzip()
13
+ let sink: TransformStreamDefaultController<Uint8Array>
14
+ /* `closed` gates enqueue: once the consumer cancels (client disconnect), the node
15
+ gzip can still emit a trailing `data` that would throw on the closed controller. */
16
+ let closed = false
17
+ gzip.on('data', (chunk: Buffer) => {
18
+ if (closed) {
19
+ return
20
+ }
21
+ /* enqueue can still race a just-closed controller (cancel is async); treat the
22
+ throw as the consumer having gone and tear down. */
23
+ try {
24
+ sink.enqueue(new Uint8Array(chunk))
25
+ } catch {
26
+ closed = true
27
+ gzip.destroy()
28
+ }
29
+ })
30
+ /* The spec'd Transformer.cancel hook (consumer-cancellation) postdates this TS lib's
31
+ Transformer type; declare it locally so the literal type-checks while the platform
32
+ (Bun) still invokes it. Typing the const sidesteps the fresh-literal excess-property
33
+ check without weakening start/transform/flush. */
34
+ const transformer: Transformer<Uint8Array, Uint8Array> & {
35
+ cancel(reason?: unknown): void
36
+ } = {
37
+ start(controller) {
38
+ sink = controller
39
+ gzip.on('error', (error) => controller.error(error))
40
+ },
41
+ /* Resolve only once the chunk is compressed AND sync-flushed, so its bytes are
42
+ on the wire before the stream pulls the next (possibly delayed) chunk. */
43
+ transform(chunk) {
44
+ return new Promise((resolve) => {
45
+ gzip.write(chunk, () => gzip.flush(constants.Z_SYNC_FLUSH, () => resolve()))
46
+ })
47
+ },
48
+ /* End the deflate stream; its trailing bytes arrive as `data` before `end`. */
49
+ flush() {
50
+ return new Promise((resolve) => {
51
+ gzip.on('end', () => resolve())
52
+ gzip.end()
53
+ })
54
+ },
55
+ /* Consumer went away — stop enqueueing and tear down the node stream. */
56
+ cancel() {
57
+ closed = true
58
+ gzip.destroy()
59
+ },
60
+ }
61
+ return new TransformStream(transformer)
62
+ }
@@ -1,5 +1,7 @@
1
1
  import { isStreamingResponse } from '../../shared/isStreamingResponse.ts'
2
2
  import { acceptsGzip } from './acceptsGzip.ts'
3
+ import { flushingGzipStream } from './flushingGzipStream.ts'
4
+ import { STREAMED_HTML_HEADER } from './STREAMED_HTML_HEADER.ts'
3
5
 
4
6
  /*
5
7
  Compressible Content-Types — text and structured-text payloads. Binary or
@@ -11,18 +13,27 @@ const COMPRESSIBLE_TYPE =
11
13
 
12
14
  /*
13
15
  Gzips a dynamic response (SSR HTML, rpc/json replies, the plain 404) when the
14
- client accepts it, piping the body through a CompressionStream so a buffered
15
- body and a streamed SSR document take the identical path. Static assets never
16
- reach here — they already carry a precompressed Content-Encoding, which
17
- short-circuits the first guard. Skipped for: already-encoded bodies, bodiless
18
- responses (204/304/HEAD), non-compressible types (images, fonts, archives),
19
- and frame-delimited streams (SSE/jsonl, where gzip buffering would stall
20
- per-frame flush). No byte-size floor: Bun doesn't expose a string body's length
21
- before send, and measuring would mean buffering the streamed SSR document —
22
- the framing overhead on the rare tiny body is negligible against compressing
23
- every page and rpc payload.
16
+ client accepts it. Static assets never reach here they already carry a
17
+ precompressed Content-Encoding, which short-circuits the first guard. Skipped
18
+ for: already-encoded bodies, bodiless responses (204/304/HEAD), non-compressible
19
+ types (images, fonts, archives), and frame-delimited streams (SSE/jsonl, where
20
+ gzip buffering would stall per-frame flush). No byte-size floor: Bun doesn't
21
+ expose a string body's length before send, and measuring would mean buffering the
22
+ body the framing overhead on the rare tiny body is negligible against
23
+ compressing every page and rpc payload.
24
+
25
+ Buffered bodies take the web CompressionStream (best ratio, one flush at close).
26
+ The streamed SSR document self-marks (STREAMED_HTML_HEADER) and takes a
27
+ per-chunk-flushing gzip instead: the plain CompressionStream buffers the head
28
+ until its deflate window fills, which defeats streaming (the browser can't
29
+ preload-scan the head or paint the pending shell until the stream nearly closes).
30
+ The marker is stripped so it never reaches the client.
24
31
  */
25
32
  export function gzipResponse(req: Request, response: Response): Response {
33
+ const streamedHtml = response.headers.has(STREAMED_HTML_HEADER)
34
+ if (streamedHtml) {
35
+ response.headers.delete(STREAMED_HTML_HEADER)
36
+ }
26
37
  if (!response.body || response.headers.has('Content-Encoding')) {
27
38
  return response
28
39
  }
@@ -38,7 +49,8 @@ export function gzipResponse(req: Request, response: Response): Response {
38
49
  headers.append('Vary', 'Accept-Encoding')
39
50
  /* The stored length no longer matches the compressed body (and is unknown for a stream). */
40
51
  headers.delete('Content-Length')
41
- return new Response(response.body.pipeThrough(new CompressionStream('gzip')), {
52
+ const compressor = streamedHtml ? flushingGzipStream() : new CompressionStream('gzip')
53
+ return new Response(response.body.pipeThrough(compressor), {
42
54
  status: response.status,
43
55
  statusText: response.statusText,
44
56
  headers,
@@ -1,45 +1,34 @@
1
1
  import { isReplayableMethod } from '../../shared/isReplayableMethod.ts'
2
- import type { CacheEntry } from '../../shared/types/CacheEntry.ts'
3
- import type { CacheSnapshot } from '../../shared/types/CacheSnapshot.ts'
4
2
  import type { CacheSnapshotEntry } from '../../shared/types/CacheSnapshotEntry.ts'
5
3
  import type { CacheStore } from '../../shared/types/CacheStore.ts'
6
4
  import { snapshotEntryFromCache } from './snapshotEntryFromCache.ts'
7
5
 
8
6
  /*
9
- Partitions the request-scoped cache for SSR. Entries settled by the time
10
- `render()` returns were consumed via `await` (render blocked on them) and ship
11
- inline in the document's `__SSR__` blob. Entries still pending were consumed
12
- via `{#await}` render emitted their pending branch without blocking so they
13
- go to `pending` for the response streamer to drain and resolve over the wire.
7
+ Snapshots the request-scoped cache for SSR at a single instant: every replayable
8
+ (GET/DELETE) entry settled by now, serialized to a wire-safe CacheSnapshotEntry the
9
+ client seeds its store from. Unsettled and non-replayable entries are skipped; a body
10
+ that can't ship (binary / streaming / rejected) drops out via snapshotEntryFromCache.
14
11
 
15
- Unlike the old buffer-everything path, this never awaits the pending promises:
16
- that's the whole point of streaming. Settled entries are read concurrently (the
17
- awaits are immediate since they're already resolved, but their body reads run in
18
- parallel); pending entries are handed back as-is for the streamer to await one
19
- chunk at a time.
12
+ Snapshots concurrently — the awaits are immediate (entries are already settled), but
13
+ their body reads run in parallel. Never blocks on an unsettled entry.
14
+
15
+ WHEN it's called decides what it sees. createUiPageRenderer calls it at render-return
16
+ for `__SSR__` that catches only top-level `await` reads (render blocked on them). A
17
+ `{#await cache()}` read does NOT appear: its expression is a thunk renderToStream runs
18
+ lazily, so its entry is created mid-stream, after this snapshot. The renderer snapshots
19
+ AGAIN once the stream has drained (entries then exist and are settled) and seeds those
20
+ over the wire — see its post-stream `__abideResolve` pass. So for a streaming page the
21
+ render-return snapshot is typically empty; the warm cache arrives over the stream.
20
22
  */
21
- export async function serializeCacheSnapshot(store: CacheStore): Promise<CacheSnapshot> {
22
- const settled: CacheEntry[] = []
23
- const pending: CacheEntry[] = []
24
- for (const entry of store.entries.values()) {
25
- /* Producer entries carry no wire request nothing to rehydrate against, skip. */
26
- if (!entry.request) {
27
- continue
28
- }
29
- if (!isReplayableMethod(entry.request.method.toUpperCase())) {
30
- continue
31
- }
32
- if (entry.settled) {
33
- settled.push(entry)
34
- } else {
35
- pending.push(entry)
36
- }
37
- }
23
+ export async function serializeCacheSnapshot(store: CacheStore): Promise<CacheSnapshotEntry[]> {
24
+ const settled = Array.from(store.entries.values()).filter(
25
+ (entry) =>
26
+ entry.settled === true &&
27
+ entry.request !== undefined &&
28
+ isReplayableMethod(entry.request.method.toUpperCase()),
29
+ )
38
30
  const snapshots = await Promise.all(
39
31
  settled.map((entry) => snapshotEntryFromCache(store, entry)),
40
32
  )
41
- const inline = snapshots.filter(
42
- (snapshot): snapshot is CacheSnapshotEntry => snapshot !== undefined,
43
- )
44
- return { inline, pending }
33
+ return snapshots.filter((snapshot): snapshot is CacheSnapshotEntry => snapshot !== undefined)
45
34
  }
@@ -36,7 +36,7 @@ every page, and `.abide` files are the only component format.
36
36
 
37
37
  ## Idioms
38
38
 
39
- - **Signals are the surface**: `state(v)`, `derived(fn)`, `effect(fn)`, `prop(name)`.
39
+ - **Signals are the surface**: `state(v)`, `derived(fn)`, `effect(fn)`, `props()`.
40
40
  You write plain assignment (`count += 1`, `items.push(x)`); the compiler lowers
41
41
  it. Templates auto-read (`{count}`); ordering and cross-references compose.
42
42
  - **Everything dynamic lives in `{ }`** — `{expr}` text, `name={expr}`,
@@ -1,12 +1,12 @@
1
1
  /* The callee names the `.abide` compiler recognises as reactive declarations
2
- (`let x = state(...)`, `linked(...)`, `computed(...)`, `prop(...)`): the shared
3
- "is this a reactive binding" allowlist read by the desugarer, the nested-script
4
- scoper, and the type-checking shadow. How each lowers — a serializable doc slot
5
- vs a `.value` cell — is decided per-site; this is only the membership set, so a
6
- new primitive is a single edit here. */
2
+ (`let x = state(...)`, `linked(...)`, `computed(...)`, and the destructuring
3
+ `const {…} = props()`): the shared "is this a reactive binding" allowlist read by
4
+ the desugarer, the nested-script scoper, and the type-checking shadow. How each
5
+ lowers — a serializable doc slot vs a `.value` cell — is decided per-site; this is
6
+ only the membership set, so a new primitive is a single edit here. */
7
7
  export const REACTIVE_CALLEES: ReadonlySet<string> = new Set([
8
8
  'state',
9
9
  'linked',
10
10
  'computed',
11
- 'prop',
11
+ 'props',
12
12
  ])
@@ -32,6 +32,7 @@ export const UI_RUNTIME_IMPORTS: { name: string; specifier: string }[] = [
32
32
  { name: 'mountSlot', specifier: 'ui/dom/mountSlot' },
33
33
  { name: 'mountChild', specifier: 'ui/dom/mountChild' },
34
34
  { name: 'hydrate', specifier: 'ui/dom/hydrate' },
35
+ { name: 'escapeKey', specifier: 'ui/runtime/escapeKey' },
35
36
  { name: 'nextBlockId', specifier: 'ui/runtime/nextBlockId' },
36
37
  { name: 'enterRenderPass', specifier: 'ui/runtime/enterRenderPass' },
37
38
  { name: 'exitRenderPass', specifier: 'ui/runtime/exitRenderPass' },
@@ -12,9 +12,10 @@ calls type-check — `scope()` is the authored reactive surface (`scope().state(
12
12
  `.computed(...)` / `.undo()` …), so it must resolve like any import. `state`/`linked`/
13
13
  `computed` are ALSO declared ambiently as a fallback for the rare bare/nested use the
14
14
  top-level rewrite doesn't project (their top-level declarations become value types, so
15
- these are normally unused — fine, the shadow disables noUnusedLocals). `prop` is sugar
16
- too, but every `prop()` declaration is rewritten away, so it never appears here.
17
- `$props` is the legacy untyped prop bag (pre-`prop()` sugar) made available raw.
15
+ these are normally unused — fine, the shadow disables noUnusedLocals). `props` is the prop
16
+ reader destructured (`const { a = 1 } = props<Shape>()`); declared returning its type
17
+ argument (default `Record<string, any>`) so each binding inherits its prop type and its
18
+ `= default` narrows.
18
19
  */
19
20
  const SHADOW_PREAMBLE = `import { effect } from '${ABIDE_PACKAGE_NAME}/ui/effect'
20
21
  import { html } from '${ABIDE_PACKAGE_NAME}/shared/html'
@@ -23,7 +24,7 @@ import { scope } from '${ABIDE_PACKAGE_NAME}/ui/scope'
23
24
  declare function state<T>(initial?: T, transform?: (next: T, previous: T) => T): { value: T }
24
25
  declare function linked<T>(seed: () => T, transform?: (next: T, previous: T) => T): { value: T }
25
26
  declare function computed<T>(compute: () => T): { readonly value: T }
26
- declare const $props: Record<string, (() => unknown) | undefined>
27
+ declare function props<T = Record<string, any>>(): T
27
28
  void [effect, html, snippet, scope]
28
29
  `
29
30
 
@@ -36,9 +37,9 @@ expressions and child-component props, with diagnostics mapped back through the
36
37
  returned segments.
37
38
 
38
39
  The script's signal surface is rewritten to value types:
39
- let count = state(0) → let count = (0)
40
- const total = computed(() => …) → const total = (() => …)()
41
- let title = prop<string>('t') Props field + `let title = props['t']`
40
+ let count = state(0) → let count = (0)
41
+ const total = computed(() => …) → const total = (() => …)()
42
+ const { a } = props<{ a: T }>() `__Props = { a: T }` + the verbatim destructure
42
43
  Everything else (functions, plain consts, imports) is emitted verbatim, so
43
44
  expressions inside it (e.g. a computed's compute body) are checked and mapped too.
44
45
  */
@@ -50,7 +51,7 @@ export function compileShadow(source: string): CompiledShadow {
50
51
  const scriptStart = leadingScript ? source.indexOf('>', leadingScript.index) + 1 : 0
51
52
  const templateStart = leadingScript ? (leadingScript.index ?? 0) + leadingScript[0].length : 0
52
53
 
53
- const { imports, types, scope, props } = analyzeScript(scriptBody, scriptStart)
54
+ const { imports, types, scope, propsShapes } = analyzeScript(scriptBody, scriptStart)
54
55
  builder.raw(SHADOW_PREAMBLE)
55
56
  for (const line of imports) {
56
57
  builder.flush(line)
@@ -62,11 +63,20 @@ export function compileShadow(source: string): CompiledShadow {
62
63
  for (const line of types) {
63
64
  builder.flush(line)
64
65
  }
65
- builder.raw(`interface __Props {\n${props.join('\n')}\n}\n`)
66
- /* async so `await` blocks are legal; never executed, so the return is void. */
67
- builder.raw('export default async function (props: __Props): Promise<void> {\n')
66
+ /* `__Props` is the parent-facing prop shape: each `props<Shape>()` destructure
67
+ contributes its whole `Shape` (intersected if there's more than one), or an empty
68
+ object for a component that reads no props. */
69
+ builder.raw(
70
+ propsShapes.length > 0
71
+ ? `type __Props = ${propsShapes.join(' & ')}\n`
72
+ : `interface __Props {}\n`,
73
+ )
74
+ /* `__props` (not `props`) so the destructuring `props()` sugar resolves to the
75
+ declared function, not this parameter. async so `await` blocks are legal; never
76
+ executed, so the return is void. */
77
+ builder.raw('export default async function (__props: __Props): Promise<void> {\n')
68
78
  /* Reference props so an all-optional bag with no reads doesn't read as unused. */
69
- builder.raw('void props;\n')
79
+ builder.raw('void __props;\n')
70
80
  for (const line of scope) {
71
81
  builder.flush(line)
72
82
  }
@@ -137,20 +147,20 @@ type ScriptAnalysis = {
137
147
  imports: ScopeLine[]
138
148
  types: ScopeLine[]
139
149
  scope: ScopeLine[]
140
- props: string[]
150
+ propsShapes: string[]
141
151
  }
142
152
 
143
153
  /* Walks the leading `<script>` and produces the shadow's module imports, the
144
- module-scope type declarations, the value-typed scope lines, and the Props
145
- interface fields. `scriptStart` is the body's absolute offset in the source, so
146
- verbatim spans map back exactly. */
154
+ module-scope type declarations, the value-typed scope lines, and the `props<Shape>()`
155
+ prop shapes. `scriptStart` is the body's absolute offset in the source, so verbatim
156
+ spans map back exactly. */
147
157
  function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis {
148
158
  const imports: ScopeLine[] = []
149
159
  const types: ScopeLine[] = []
150
160
  const scope: ScopeLine[] = []
151
- const props: string[] = []
161
+ const propsShapes: string[] = []
152
162
  if (scriptBody.trim() === '') {
153
- return { imports, types, scope, props }
163
+ return { imports, types, scope, propsShapes }
154
164
  }
155
165
  const file = ts.createSourceFile('script.ts', scriptBody, ts.ScriptTarget.Latest, true)
156
166
  /* A verbatim span: original text + the segment mapping it back, relative to the
@@ -180,10 +190,10 @@ function analyzeScript(scriptBody: string, scriptStart: number): ScriptAnalysis
180
190
  continue
181
191
  }
182
192
  for (const declaration of reactive) {
183
- scope.push(scopeLineFor(declaration, props, verbatim, span))
193
+ scope.push(scopeLineFor(declaration, propsShapes, verbatim, span))
184
194
  }
185
195
  }
186
- return { imports, types, scope, props }
196
+ return { imports, types, scope, propsShapes }
187
197
  }
188
198
 
189
199
  /* Value-projects a nested control-flow `<script>` body the way `analyzeScript`
@@ -207,8 +217,8 @@ function projectNestedScript(code: string): string {
207
217
  .join('\n')
208
218
  }
209
219
 
210
- /* The `state`/`computed`/`prop` declarations in a variable statement, or undefined
211
- if it isn't one declaring them (so the caller emits it verbatim). A statement
220
+ /* The `state`/`computed`/`linked`/`props()` declarations in a variable statement, or
221
+ undefined if it isn't one declaring them (so the caller emits it verbatim). A statement
212
222
  mixing reactive and plain declarations is rare; treated as all-verbatim. */
213
223
  function reactiveDeclarations(statement: ts.Statement): ts.VariableDeclaration[] | undefined {
214
224
  if (!ts.isVariableStatement(statement)) {
@@ -220,7 +230,7 @@ function reactiveDeclarations(statement: ts.Statement): ts.VariableDeclaration[]
220
230
  }
221
231
 
222
232
  /* The callee name of a `NAME = state(...)` / `linked(...)` / `computed(...)` /
223
- `prop(...)` decl — bare or the explicit scope form (`scope().state(...)` / `c.state(...)`),
233
+ `props()` decl — bare or the explicit scope form (`scope().state(...)` / `c.state(...)`),
224
234
  receiver-agnostic (the method name marks it reactive). */
225
235
  function signalCallee(declaration: ts.VariableDeclaration): string | undefined {
226
236
  const initializer = declaration.initializer
@@ -237,10 +247,10 @@ function signalCallee(declaration: ts.VariableDeclaration): string | undefined {
237
247
  }
238
248
 
239
249
  /* Builds one scope line for a reactive declaration, projecting it to its value
240
- type. `prop` also contributes a Props field (pushed into `props`). */
250
+ type. A `props()` destructure contributes its whole shape (pushed into `propsShapes`). */
241
251
  function scopeLineFor(
242
252
  declaration: ts.VariableDeclaration,
243
- props: string[],
253
+ propsShapes: string[],
244
254
  verbatim: (node: ts.Node) => string,
245
255
  span: (node: ts.Node, prefixLength: number) => ShadowMapping,
246
256
  ): ScopeLine {
@@ -250,6 +260,14 @@ function scopeLineFor(
250
260
  const callee = ts.isPropertyAccessExpression(call.expression)
251
261
  ? call.expression.name.text
252
262
  : (call.expression as ts.Identifier).text
263
+ if (callee === 'props') {
264
+ /* `const {…} = props<Shape>()`: the type arg (default `Record<string, any>`)
265
+ IS the parent-facing prop shape, and the destructure projects verbatim against
266
+ the declared typed `props()` so each binding inherits its value type. */
267
+ const shape = call.typeArguments?.[0]
268
+ propsShapes.push(shape === undefined ? 'Record<string, any>' : verbatim(shape))
269
+ return { text: `const ${verbatim(declaration)};`, segments: [span(declaration, 6)] }
270
+ }
253
271
  if (callee === 'state') {
254
272
  /* state<T>(initial): T is the value type — carry it onto the `let` so an
255
273
  explicit annotation isn't lost to `any`/`any[]` inference of the initial. */
@@ -273,40 +291,27 @@ function scopeLineFor(
273
291
  segments: [span(declaration.name, 4), span(init, prefix.length)],
274
292
  }
275
293
  }
276
- if (callee === 'computed' || callee === 'linked') {
277
- /* computed<T>(compute) / linked<T>(seed): T is the value type the call's
278
- first arg is a thunk, so invoking it yields the value. Annotate so an
279
- explicit type argument isn't lost to inference of the thunk's return. */
280
- const typeNode = call.typeArguments?.[0]
281
- const annotation = typeNode === undefined ? '' : `: ${verbatim(typeNode)}`
282
- const fn = call.arguments[0]
283
- /* `linked` is a writable `State<T>` at runtime (it reseeds AND accepts `.value =`
284
- writes), so project it as `let`; `computed` is genuinely read-only, so `const`. */
285
- const keyword = callee === 'linked' ? 'let' : 'const'
286
- /* binding-name map offset = past the keyword + space (`let ` = 4, `const ` = 6) */
287
- const keywordOffset = keyword.length + 1
288
- if (fn === undefined) {
289
- return {
290
- text: `${keyword} ${name} = undefined;`,
291
- segments: [span(declaration.name, keywordOffset)],
292
- }
293
- }
294
- const prefix = `${keyword} ${name}${annotation} = (`
294
+ /* computed<T>(compute) / linked<T>(seed) the only callees left: T is the value
295
+ type the call's first arg is a thunk, so invoking it yields the value. Annotate
296
+ so an explicit type argument isn't lost to inference of the thunk's return. */
297
+ const typeNode = call.typeArguments?.[0]
298
+ const annotation = typeNode === undefined ? '' : `: ${verbatim(typeNode)}`
299
+ const fn = call.arguments[0]
300
+ /* `linked` is a writable `State<T>` at runtime (it reseeds AND accepts `.value =`
301
+ writes), so project it as `let`; `computed` is genuinely read-only, so `const`. */
302
+ const keyword = callee === 'linked' ? 'let' : 'const'
303
+ /* binding-name map offset = past the keyword + space (`let ` = 4, `const ` = 6) */
304
+ const keywordOffset = keyword.length + 1
305
+ if (fn === undefined) {
295
306
  return {
296
- text: `${prefix}${verbatim(fn)})();`,
297
- segments: [span(declaration.name, keywordOffset), span(fn, prefix.length)],
307
+ text: `${keyword} ${name} = undefined;`,
308
+ segments: [span(declaration.name, keywordOffset)],
298
309
  }
299
310
  }
300
- /* prop<T>('key'): Props field `key[?]: T`, scope binding read from props. */
301
- const key = call.arguments[0]
302
- const keyText = key !== undefined && ts.isStringLiteralLike(key) ? key.text : name
303
- const typeNode = call.typeArguments?.[0]
304
- const typeText = typeNode === undefined ? 'unknown' : verbatim(typeNode)
305
- const optional = typeNode === undefined || /\bundefined\b/.test(typeText)
306
- props.push(` ${JSON.stringify(keyText)}${optional ? '?' : ''}: ${typeText}`)
311
+ const prefix = `${keyword} ${name}${annotation} = (`
307
312
  return {
308
- text: `let ${name} = props[${JSON.stringify(keyText)}];`,
309
- segments: [span(declaration.name, 4)],
313
+ text: `${prefix}${verbatim(fn)})();`,
314
+ segments: [span(declaration.name, keywordOffset), span(fn, prefix.length)],
310
315
  }
311
316
  }
312
317