@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.
- package/AGENTS.md +3 -3
- package/CHANGELOG.md +36 -0
- package/README.md +2 -2
- package/package.json +2 -1
- package/src/abideResolverPlugin.ts +53 -9
- package/src/lib/server/runtime/STREAMED_HTML_HEADER.ts +13 -0
- package/src/lib/server/runtime/buildPreloadManifest.ts +151 -0
- package/src/lib/server/runtime/createServer.ts +4 -1
- package/src/lib/server/runtime/createUiPageRenderer.ts +104 -9
- package/src/lib/server/runtime/flushingGzipStream.ts +62 -0
- package/src/lib/server/runtime/gzipResponse.ts +23 -11
- package/src/lib/server/runtime/serializeCacheSnapshot.ts +22 -33
- package/src/lib/ui/README.md +1 -1
- package/src/lib/ui/compile/REACTIVE_CALLEES.ts +6 -6
- package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
- package/src/lib/ui/compile/compileShadow.ts +60 -55
- package/src/lib/ui/compile/desugarSignals.ts +93 -38
- package/src/lib/ui/compile/lowerDocAccess.ts +26 -11
- package/src/lib/ui/compile/parseTemplate.ts +8 -5
- package/src/lib/ui/compile/prepareNestedScript.ts +2 -2
- package/src/lib/ui/compile/renameSignalRefs.ts +153 -23
- package/src/lib/ui/compile/types/TemplateAttr.ts +4 -2
- package/src/lib/ui/dom/applyResolved.ts +27 -7
- package/src/lib/ui/installHotBridge.ts +2 -0
- package/src/lib/ui/runtime/escapeKey.ts +10 -4
- package/src/lib/ui/seedStreamedResolution.ts +22 -0
- package/src/lib/ui/startClient.ts +17 -3
- package/src/lib/shared/types/CacheSnapshot.ts +0 -16
|
@@ -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
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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<
|
|
22
|
-
const settled
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
42
|
-
(snapshot): snapshot is CacheSnapshotEntry => snapshot !== undefined,
|
|
43
|
-
)
|
|
44
|
-
return { inline, pending }
|
|
33
|
+
return snapshots.filter((snapshot): snapshot is CacheSnapshotEntry => snapshot !== undefined)
|
|
45
34
|
}
|
package/src/lib/ui/README.md
CHANGED
|
@@ -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)`, `
|
|
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(...)`,
|
|
3
|
-
"is this a reactive binding" allowlist read by
|
|
4
|
-
scoper, and the type-checking shadow. How each
|
|
5
|
-
vs a `.value` cell — is decided per-site; this is
|
|
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
|
-
'
|
|
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). `
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
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)
|
|
40
|
-
const total = computed(() => …)
|
|
41
|
-
|
|
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,
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
161
|
+
const propsShapes: string[] = []
|
|
152
162
|
if (scriptBody.trim() === '') {
|
|
153
|
-
return { imports, types, scope,
|
|
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,
|
|
193
|
+
scope.push(scopeLineFor(declaration, propsShapes, verbatim, span))
|
|
184
194
|
}
|
|
185
195
|
}
|
|
186
|
-
return { imports, types, scope,
|
|
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`/`
|
|
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
|
-
`
|
|
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. `
|
|
250
|
+
type. A `props()` destructure contributes its whole shape (pushed into `propsShapes`). */
|
|
241
251
|
function scopeLineFor(
|
|
242
252
|
declaration: ts.VariableDeclaration,
|
|
243
|
-
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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: `${
|
|
297
|
-
segments: [span(declaration.name, keywordOffset)
|
|
307
|
+
text: `${keyword} ${name} = undefined;`,
|
|
308
|
+
segments: [span(declaration.name, keywordOffset)],
|
|
298
309
|
}
|
|
299
310
|
}
|
|
300
|
-
|
|
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:
|
|
309
|
-
segments: [span(declaration.name,
|
|
313
|
+
text: `${prefix}${verbatim(fn)})();`,
|
|
314
|
+
segments: [span(declaration.name, keywordOffset), span(fn, prefix.length)],
|
|
310
315
|
}
|
|
311
316
|
}
|
|
312
317
|
|