@abide/abide 0.39.0 → 0.40.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.
Files changed (71) hide show
  1. package/AGENTS.md +318 -146
  2. package/CHANGELOG.md +89 -0
  3. package/README.md +77 -79
  4. package/package.json +1 -1
  5. package/src/lib/server/rpc/parseArgs.ts +10 -1
  6. package/src/lib/server/rpc/runWithVerbTimeout.ts +7 -9
  7. package/src/lib/server/rpc/types/VerbHelper.ts +18 -21
  8. package/src/lib/server/runtime/SSR_SWAP_SCRIPT.ts +8 -7
  9. package/src/lib/server/sockets/createSocketDispatcher.ts +12 -3
  10. package/src/lib/server/sockets/defineSocket.ts +3 -1
  11. package/src/lib/shared/REF_JSON_HEADER.ts +9 -0
  12. package/src/lib/shared/REF_JSON_TAGS.ts +31 -0
  13. package/src/lib/shared/assertExhaustive.ts +14 -0
  14. package/src/lib/shared/buildRpcRequest.ts +8 -1
  15. package/src/lib/shared/decodeRefJson.ts +110 -0
  16. package/src/lib/shared/encodeRefJson.ts +106 -0
  17. package/src/lib/test/createTestSocketChannel.ts +6 -2
  18. package/src/lib/ui/compile/TS_PRINTER.ts +7 -0
  19. package/src/lib/ui/compile/analyzeComponent.ts +15 -16
  20. package/src/lib/ui/compile/assertRuntimeHelpersBound.ts +66 -0
  21. package/src/lib/ui/compile/assertTranspiles.ts +19 -0
  22. package/src/lib/ui/compile/compileModule.ts +56 -34
  23. package/src/lib/ui/compile/compileSSR.ts +1 -3
  24. package/src/lib/ui/compile/compileShadow.ts +14 -7
  25. package/src/lib/ui/compile/desugarSignals.ts +168 -107
  26. package/src/lib/ui/compile/generateBuild.ts +49 -59
  27. package/src/lib/ui/compile/generateSSR.ts +30 -21
  28. package/src/lib/ui/compile/hoistCells.ts +2 -2
  29. package/src/lib/ui/compile/isWhitespaceText.ts +11 -0
  30. package/src/lib/ui/compile/lowerContext.ts +23 -10
  31. package/src/lib/ui/compile/lowerDocAccess.ts +29 -3
  32. package/src/lib/ui/compile/lowerScript.ts +64 -0
  33. package/src/lib/ui/compile/parseTemplate.ts +74 -0
  34. package/src/lib/ui/compile/renameSignalRefs.ts +160 -90
  35. package/src/lib/ui/compile/resolveBranches.ts +21 -0
  36. package/src/lib/ui/compile/stripEffects.ts +22 -17
  37. package/src/lib/ui/compile/types/AnalyzedComponent.ts +5 -0
  38. package/src/lib/ui/compile/types/TemplateNode.ts +12 -2
  39. package/src/lib/ui/createScope.ts +14 -0
  40. package/src/lib/ui/dom/appendText.ts +2 -3
  41. package/src/lib/ui/dom/appendTextAt.ts +2 -3
  42. package/src/lib/ui/dom/applyResolved.ts +5 -8
  43. package/src/lib/ui/dom/awaitBlock.ts +22 -2
  44. package/src/lib/ui/dom/each.ts +4 -0
  45. package/src/lib/ui/dom/on.ts +7 -0
  46. package/src/lib/ui/dom/parseRawNodes.ts +17 -0
  47. package/src/lib/ui/dom/switchBlock.ts +4 -0
  48. package/src/lib/ui/dom/when.ts +4 -0
  49. package/src/lib/ui/installInspectorBridge.ts +3 -1
  50. package/src/lib/ui/navigate.ts +28 -3
  51. package/src/lib/ui/renderToStream.ts +17 -9
  52. package/src/lib/ui/resumeSeedScript.ts +16 -6
  53. package/src/lib/ui/router.ts +108 -15
  54. package/src/lib/ui/runtime/NODE_STATE.ts +22 -0
  55. package/src/lib/ui/runtime/RESUME.ts +13 -6
  56. package/src/lib/ui/runtime/clientPage.ts +114 -9
  57. package/src/lib/ui/runtime/createComputedNode.ts +5 -3
  58. package/src/lib/ui/runtime/createEffectNode.ts +3 -1
  59. package/src/lib/ui/runtime/createSignalNode.ts +4 -2
  60. package/src/lib/ui/runtime/flushEffects.ts +8 -5
  61. package/src/lib/ui/runtime/historyEntries.ts +39 -1
  62. package/src/lib/ui/runtime/localStoragePersistence.ts +14 -8
  63. package/src/lib/ui/runtime/readNode.ts +8 -7
  64. package/src/lib/ui/runtime/runNode.ts +18 -2
  65. package/src/lib/ui/runtime/scope.ts +12 -1
  66. package/src/lib/ui/runtime/trigger.ts +40 -24
  67. package/src/lib/ui/runtime/types/ReactiveNode.ts +4 -1
  68. package/src/lib/ui/runtime/updateIfNecessary.ts +40 -0
  69. package/src/lib/ui/socketChannel.ts +5 -2
  70. package/src/lib/ui/tryEncodeResume.ts +20 -0
  71. package/src/lib/ui/types/Scope.ts +9 -3
@@ -0,0 +1,110 @@
1
+ /*
2
+ Inverse of encodeRefJson — rebuild a value graph (cycles, shared references, and the
3
+ JSON-hostile types) from a `[rootValue, slots]` ref-json string. Two passes are
4
+ required: pass one allocates every hoisted container as an empty shell, pass two fills
5
+ them. The shell must exist before its contents are filled so a back-reference to an
6
+ ancestor resolves to the already-allocated object — which is what reconnects a cycle.
7
+ Inline primitives need no shell; they decode directly where they sit.
8
+ */
9
+ import { REF_JSON_TAGS } from './REF_JSON_TAGS'
10
+
11
+ export function decodeRefJson(text: string): unknown {
12
+ const parsed = JSON.parse(text)
13
+ if (!Array.isArray(parsed) || parsed.length !== 2 || !Array.isArray(parsed[1])) {
14
+ // encodeRefJson always emits a `[rootValue, slots]` pair; anything else isn't our format.
15
+ throw new TypeError('decodeRefJson: not a ref-json payload')
16
+ }
17
+ const [rootValue, slots] = parsed as [unknown, unknown[]]
18
+ // Pass 1: an empty container shell per slot. Pass 2 fills them so a ref to an ancestor finds its shell.
19
+ const shells = slots.map(buildShell)
20
+ slots.forEach((slot, index) => {
21
+ fillShell(slot, shells[index], shells)
22
+ })
23
+ return resolveValue(rootValue, shells)
24
+ }
25
+
26
+ // Empty container matching a slot's kind. Every slot is a container (only containers are hoisted).
27
+ function buildShell(slot: unknown): unknown {
28
+ if (Array.isArray(slot)) {
29
+ if (slot[0] === REF_JSON_TAGS.ARRAY) {
30
+ return []
31
+ }
32
+ if (slot[0] === REF_JSON_TAGS.MAP) {
33
+ return new Map()
34
+ }
35
+ if (slot[0] === REF_JSON_TAGS.SET) {
36
+ return new Set()
37
+ }
38
+ }
39
+ return {}
40
+ }
41
+
42
+ // Decode an inline value: bare primitive (itself), a leaf-special, or a reference to a built shell.
43
+ function resolveValue(value: unknown, shells: unknown[]): unknown {
44
+ if (!Array.isArray(value)) {
45
+ return value
46
+ }
47
+ switch (value[0]) {
48
+ case REF_JSON_TAGS.REF:
49
+ return shells[value[1] as number]
50
+ case REF_JSON_TAGS.UNDEFINED:
51
+ return undefined
52
+ case REF_JSON_TAGS.DATE:
53
+ return new Date(value[1] as number)
54
+ case REF_JSON_TAGS.REGEXP:
55
+ return new RegExp(value[1] as string, value[2] as string)
56
+ case REF_JSON_TAGS.BIGINT:
57
+ return BigInt(value[1] as string)
58
+ case REF_JSON_TAGS.NUMBER:
59
+ return decodeNumberToken(value[1] as string)
60
+ default:
61
+ throw new TypeError(`decodeRefJson: unknown value tag ${String(value[0])}`)
62
+ }
63
+ }
64
+
65
+ // Fill a container shell from its slot, resolving each inline child.
66
+ function fillShell(slot: unknown, shell: unknown, shells: unknown[]): void {
67
+ if (!Array.isArray(slot)) {
68
+ const target = shell as Record<string, unknown>
69
+ const record = slot as Record<string, unknown>
70
+ for (const key of Object.keys(record)) {
71
+ target[key] = resolveValue(record[key], shells)
72
+ }
73
+ return
74
+ }
75
+ if (slot[0] === REF_JSON_TAGS.ARRAY) {
76
+ const target = shell as unknown[]
77
+ // Loop (not push(...spread)) so a huge array can't blow the call-stack arg limit.
78
+ for (let index = 1; index < slot.length; index++) {
79
+ target.push(resolveValue(slot[index], shells))
80
+ }
81
+ return
82
+ }
83
+ if (slot[0] === REF_JSON_TAGS.MAP) {
84
+ const target = shell as Map<unknown, unknown>
85
+ for (const [key, val] of slot[1] as [unknown, unknown][]) {
86
+ target.set(resolveValue(key, shells), resolveValue(val, shells))
87
+ }
88
+ return
89
+ }
90
+ if (slot[0] === REF_JSON_TAGS.SET) {
91
+ const target = shell as Set<unknown>
92
+ for (const member of slot[1] as unknown[]) {
93
+ target.add(resolveValue(member, shells))
94
+ }
95
+ }
96
+ }
97
+
98
+ // Reverse of encodeRefJson's numberToken.
99
+ function decodeNumberToken(token: string): number {
100
+ if (token === 'NaN') {
101
+ return Number.NaN
102
+ }
103
+ if (token === 'Infinity') {
104
+ return Number.POSITIVE_INFINITY
105
+ }
106
+ if (token === '-Infinity') {
107
+ return Number.NEGATIVE_INFINITY
108
+ }
109
+ return -0
110
+ }
@@ -0,0 +1,106 @@
1
+ /*
2
+ Serialise an arbitrary value graph to a JSON string that survives cycles and shared
3
+ references — the gap JSON.stringify can't cross. Only mutable CONTAINERS (objects,
4
+ arrays, Maps, Sets) are hoisted into a flat `slots` array and referenced by index, so
5
+ a cycle becomes a back-reference instead of infinite recursion, and two paths to the
6
+ same container decode back to the same object. Primitives are encoded INLINE at their
7
+ position (not hoisted), so a primitive-heavy payload stays near plain-JSON size and
8
+ speed. Beyond plain objects/arrays it round-trips the types JSON drops or coerces:
9
+ undefined, bigint, NaN, ±Infinity, -0, Date, RegExp, Map and Set. Functions and
10
+ symbols can't be serialised and encode as undefined, matching JSON.stringify. Output
11
+ shape is `[rootValue, slots]`. Decode with decodeRefJson. Not streaming — the whole
12
+ graph is walked up front.
13
+ */
14
+ import { REF_JSON_TAGS } from './REF_JSON_TAGS'
15
+
16
+ export function encodeRefJson(value: unknown): string {
17
+ // Hoisted containers only; slots[i] is addressed by ['~r', i]. Primitives stay inline.
18
+ const slots: unknown[] = []
19
+ // Container identity → slot index: a revisited container emits a back-reference instead of recursing.
20
+ const ids = new Map<object, number>()
21
+
22
+ // Hoist a container to a slot (once) and return its index. Reserve the index BEFORE encoding
23
+ // children so a cyclic child resolves back to this slot.
24
+ function intern(container: object): number {
25
+ const seen = ids.get(container)
26
+ if (seen !== undefined) {
27
+ return seen
28
+ }
29
+ const index = slots.length
30
+ slots.push(0)
31
+ ids.set(container, index)
32
+ slots[index] = encodeContainer(container)
33
+ return index
34
+ }
35
+
36
+ // Inline encoding of a value at its position: bare primitive, leaf-special tag, or a container reference.
37
+ function encodeValue(input: unknown): unknown {
38
+ if (input === null) {
39
+ return null
40
+ }
41
+ const type = typeof input
42
+ if (type === 'string' || type === 'boolean') {
43
+ return input
44
+ }
45
+ if (type === 'number') {
46
+ const numeric = input as number
47
+ // -0, NaN, ±Infinity can't round-trip as bare JSON; tag them.
48
+ if (!Number.isFinite(numeric) || Object.is(numeric, -0)) {
49
+ return [REF_JSON_TAGS.NUMBER, numberToken(numeric)]
50
+ }
51
+ return numeric
52
+ }
53
+ if (type === 'bigint') {
54
+ return [REF_JSON_TAGS.BIGINT, (input as bigint).toString()]
55
+ }
56
+ // undefined | function | symbol — not representable; fold to undefined as JSON drops them.
57
+ if (type !== 'object') {
58
+ return [REF_JSON_TAGS.UNDEFINED]
59
+ }
60
+ if (input instanceof Date) {
61
+ return [REF_JSON_TAGS.DATE, input.getTime()]
62
+ }
63
+ if (input instanceof RegExp) {
64
+ return [REF_JSON_TAGS.REGEXP, input.source, input.flags]
65
+ }
66
+ // Mutable container: hoist for identity and reference it — primitives above never reach here.
67
+ return [REF_JSON_TAGS.REF, intern(input as object)]
68
+ }
69
+
70
+ // A hoisted container's slot: a tagged array (or plain object) whose nested values are encoded inline.
71
+ function encodeContainer(container: object): unknown {
72
+ if (Array.isArray(container)) {
73
+ return [REF_JSON_TAGS.ARRAY, ...container.map(encodeValue)]
74
+ }
75
+ if (container instanceof Map) {
76
+ return [
77
+ REF_JSON_TAGS.MAP,
78
+ Array.from(container, ([key, val]) => [encodeValue(key), encodeValue(val)]),
79
+ ]
80
+ }
81
+ if (container instanceof Set) {
82
+ return [REF_JSON_TAGS.SET, Array.from(container, encodeValue)]
83
+ }
84
+ const record = container as Record<string, unknown>
85
+ // Own enumerable keys, built in one loop — ~2× cheaper than Object.fromEntries(keys.map(...))
86
+ // (no intermediate pairs array / closures) on this per-object always-hot path.
87
+ const encoded: Record<string, unknown> = {}
88
+ for (const key of Object.keys(record)) {
89
+ encoded[key] = encodeValue(record[key])
90
+ }
91
+ return encoded
92
+ }
93
+
94
+ return JSON.stringify([encodeValue(value), slots])
95
+ }
96
+
97
+ // Stable token for the numbers JSON can't carry.
98
+ function numberToken(value: number): string {
99
+ if (Object.is(value, -0)) {
100
+ return '-0'
101
+ }
102
+ if (Number.isNaN(value)) {
103
+ return 'NaN'
104
+ }
105
+ return value > 0 ? 'Infinity' : '-Infinity'
106
+ }
@@ -3,6 +3,8 @@ import type { SocketClientFrame } from '../server/sockets/types/SocketClientFram
3
3
  import type { SocketServerFrame } from '../server/sockets/types/SocketServerFrame.ts'
4
4
  import { buildSocketOverChannel } from '../shared/buildSocketOverChannel.ts'
5
5
  import { createSocketSubRegistry } from '../shared/createSocketSubRegistry.ts'
6
+ import { decodeRefJson } from '../shared/decodeRefJson.ts'
7
+ import { encodeRefJson } from '../shared/encodeRefJson.ts'
6
8
 
7
9
  /*
8
10
  Test-side substitute for the browser socketChannel: one ws to the booted
@@ -37,7 +39,8 @@ export function createTestSocketChannel(wsUrl: string): {
37
39
  }
38
40
 
39
41
  function send(frame: SocketClientFrame): void {
40
- const message = JSON.stringify(frame)
42
+ // ref-json, matching the browser channel + the server's dispatcher/publish codec.
43
+ const message = encodeRefJson(frame)
41
44
  if (ws.readyState === WebSocket.OPEN) {
42
45
  ws.send(message)
43
46
  return
@@ -53,7 +56,8 @@ export function createTestSocketChannel(wsUrl: string): {
53
56
  ws.addEventListener('message', (event) => {
54
57
  let frame: SocketServerFrame
55
58
  try {
56
- frame = JSON.parse(event.data as string) as SocketServerFrame
59
+ // The server sends ref-json frames (createSocketDispatcher/defineSocket); decode to match.
60
+ frame = decodeRefJson(event.data as string) as SocketServerFrame
57
61
  } catch {
58
62
  return
59
63
  }
@@ -0,0 +1,7 @@
1
+ import ts from 'typescript'
2
+
3
+ /* The shared TypeScript printer every compile pass prints its transformed tree with.
4
+ A `ts.Printer` is stateless and reusable, so the passes share one instance rather
5
+ than each re-creating the same `{ newLine: LineFeed }` printer. LineFeed keeps the
6
+ emitted source `\n`-delimited regardless of the host platform. */
7
+ export const TS_PRINTER = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed })
@@ -1,5 +1,4 @@
1
- import { desugarSignals } from './desugarSignals.ts'
2
- import { lowerDocAccess } from './lowerDocAccess.ts'
1
+ import { lowerScript } from './lowerScript.ts'
3
2
  import { parseTemplate } from './parseTemplate.ts'
4
3
  import { scopeCss } from './scopeCss.ts'
5
4
  import type { AnalyzedComponent } from './types/AnalyzedComponent.ts'
@@ -30,19 +29,18 @@ export function analyzeComponent(source: string, scopeSeed?: string): AnalyzedCo
30
29
  const scriptBody = (scriptMatch?.[1] ?? '').trim()
31
30
  const template = source.replace(/^\s*<script[^>]*>[\s\S]*?<\/script>/, '').trim()
32
31
 
33
- const { code: desugared, stateNames, derivedNames, computedNames } = desugarSignals(scriptBody)
34
- const lowered = desugared.trim() === '' ? '' : lowerDocAccess(desugared, 'model')
35
- /* Hoist top-level import statements (e.g. child components) out of the script
36
- so the module wrapper can place them at module scope they can't live
37
- inside the mount callback / render function. */
38
- const imports: string[] = []
39
- const script = lowered
40
- .replace(/^[ \t]*import\s[^\n]*$/gm, (line) => {
41
- imports.push(line.trim())
42
- return ''
43
- })
44
- .replace(/\n{2,}/g, '\n')
45
- .trim()
32
+ /* `lowerScript` parses the script ONCE and chains signal desugaring, reference
33
+ renaming, and doc-access lowering over that single tree, then hoists top-level
34
+ imports off the tree structurally imports live at module scope, not inside the
35
+ mount/render function the body becomes. It returns the collected signal name sets. */
36
+ const {
37
+ body: script,
38
+ ssrBody: ssrScript,
39
+ imports,
40
+ stateNames,
41
+ derivedNames,
42
+ computedNames,
43
+ } = lowerScript(scriptBody)
46
44
  /* The parser keeps each `<style>` as an in-place node (one inside an expression
47
45
  is text, never a node). `annotateScopes` mutates the tree — assigning each
48
46
  style its scope attribute and stamping covered elements — and returns the
@@ -51,7 +49,8 @@ export function analyzeComponent(source: string, scopeSeed?: string): AnalyzedCo
51
49
  const styles = annotateScopes(nodes, [], scopeSeed, { count: 0 })
52
50
  return {
53
51
  script,
54
- imports: imports.join('\n'),
52
+ ssrScript,
53
+ imports,
55
54
  stateNames,
56
55
  derivedNames,
57
56
  computedNames,
@@ -0,0 +1,66 @@
1
+ import ts from 'typescript'
2
+ import { UI_RUNTIME_IMPORTS } from './UI_RUNTIME_IMPORTS.ts'
3
+
4
+ /*
5
+ Independent backstop on the per-component dead-import filter (`compileModule`). The filter
6
+ decides which runtime helpers to import by reading the generated output's identifiers; if it
7
+ ever undercounts — as a raw token scan once did, mis-reading the tail of a module after a
8
+ `${…}` template substitution — a helper gets CALLED but never imported, and the bundle throws
9
+ `ReferenceError` the instant `build()` runs. The router escalates that into a reload loop, so
10
+ the failure is both opaque and unrecoverable.
11
+
12
+ This re-derives the same question a DIFFERENT way (so it can't share the filter's blind spot):
13
+ walk the final module's AST, find every call whose callee is a bare runtime-helper identifier,
14
+ and require that name to be imported or locally bound. A helper name inside a string / template /
15
+ comment is not a `CallExpression` callee, so a docs component quoting framework code
16
+ (`mount(...)` in a snippet) never false-positives. Compile-time only; never on the hot path.
17
+ */
18
+ export function assertRuntimeHelpersBound(module: string, context: string): void {
19
+ const helperNames = new Set(UI_RUNTIME_IMPORTS.map((entry) => entry.name))
20
+ const source = ts.createSourceFile(
21
+ 'module.ts',
22
+ module,
23
+ ts.ScriptTarget.Latest,
24
+ /* setParentNodes */ true,
25
+ )
26
+ /* Names a bare call can resolve to: every import binding plus every declared name.
27
+ Collected generously (any identifier in a binding position) — over-approximating the
28
+ bound set only risks missing a defect, never raising a false alarm on valid output. */
29
+ const bound = new Set<string>()
30
+ const calledHelpers: { name: string; position: number }[] = []
31
+ const visit = (node: ts.Node): void => {
32
+ if (
33
+ (ts.isVariableDeclaration(node) ||
34
+ ts.isFunctionDeclaration(node) ||
35
+ ts.isParameter(node) ||
36
+ ts.isBindingElement(node) ||
37
+ ts.isImportSpecifier(node) ||
38
+ ts.isImportClause(node) ||
39
+ ts.isNamespaceImport(node)) &&
40
+ node.name !== undefined &&
41
+ ts.isIdentifier(node.name)
42
+ ) {
43
+ bound.add(node.name.text)
44
+ }
45
+ if (
46
+ ts.isCallExpression(node) &&
47
+ ts.isIdentifier(node.expression) &&
48
+ helperNames.has(node.expression.text)
49
+ ) {
50
+ calledHelpers.push({
51
+ name: node.expression.text,
52
+ position: node.expression.getStart(source),
53
+ })
54
+ }
55
+ node.forEachChild(visit)
56
+ }
57
+ visit(source)
58
+
59
+ const unbound = calledHelpers.find((call) => !bound.has(call.name))
60
+ if (unbound !== undefined) {
61
+ const { line, character } = source.getLineAndCharacterOfPosition(unbound.position)
62
+ throw new Error(
63
+ `[abide] ${context} calls runtime helper \`${unbound.name}\` at line ${line + 1}:${character + 1} but never imports it — the dead-import filter dropped it. Please report this with the component source.\nOutput:\n${module}`,
64
+ )
65
+ }
66
+ }
@@ -0,0 +1,19 @@
1
+ /*
2
+ Fail-loud guard for generated code. The compile passes build modules as strings and
3
+ AST — a corruption (an un-handled rewrite position, a generator bug, a stale lowering)
4
+ otherwise ships as a broken bundle that fails opaquely at load. Transpiling the output
5
+ turns it into a compile-time error naming the stage and showing the offending source.
6
+ `context` labels which stage produced it. Compile-time only; never on the hot path.
7
+ */
8
+
9
+ const transpiler = new Bun.Transpiler({ loader: 'ts' })
10
+
11
+ export function assertTranspiles(code: string, context: string): void {
12
+ try {
13
+ transpiler.transformSync(code)
14
+ } catch (error) {
15
+ throw new Error(
16
+ `[abide] ${context} produced invalid syntax — please report this with the component source. Output:\n${code}\n\n${String(error)}`,
17
+ )
18
+ }
19
+ }
@@ -1,5 +1,8 @@
1
+ import ts from 'typescript'
1
2
  import { ABIDE_PACKAGE_NAME } from '../../shared/ABIDE_PACKAGE_NAME.ts'
2
3
  import { analyzeComponent } from './analyzeComponent.ts'
4
+ import { assertRuntimeHelpersBound } from './assertRuntimeHelpersBound.ts'
5
+ import { assertTranspiles } from './assertTranspiles.ts'
3
6
  import { compileComponent } from './compileComponent.ts'
4
7
  import { compileSSR } from './compileSSR.ts'
5
8
  import { UI_RUNTIME_IMPORTS } from './UI_RUNTIME_IMPORTS.ts'
@@ -58,14 +61,6 @@ if (!hotReplace(${id}, component)) location.reload()
58
61
  }
59
62
 
60
63
  const ssrBody = indent(compileSSR(source, isLayout, options.moduleId, analyzed))
61
- /* Per-component dead-import elimination: emit only the runtime names this module
62
- actually references. A component that uses no `each`/`await`/`html` shouldn't
63
- drag those modules into its chunk. The package isn't globally side-effect-free
64
- (the dev/runtime entries register globals), so a bundler can't tree-shake the
65
- unused imports for us — but the generated code is the one place that knows
66
- exactly which names it emitted, so it filters here. A name absent from the body
67
- is unreferenced; erring toward inclusion (a stray match in user script) only
68
- keeps a harmless unused import, never drops a needed one. */
69
64
  const moduleBody = `function build(host, $props) {
70
65
  ${body}
71
66
  }
@@ -89,37 +84,37 @@ component.hydrate = hydrateInto
89
84
  component.build = build
90
85
  component.hydratable = ${analyzed.hydratable}
91
86
  ${options.moduleId === undefined ? '' : `component.__abideId = ${JSON.stringify(options.moduleId)}\n`}`
92
- /* Scope each name to the surface that genuinely references it. The SSR body
93
- carries fixed boilerplate ($attr/$text helpers, `<!--abide:html-->` markers,
94
- the `{ html, state, awaits }` return shape) whose substrings would falsely
95
- match value/DOM imports so client helpers are matched against the client
96
- build, render-pass helpers against the SSR body, and the two wrapper calls
97
- (mount/hydrate) are always present. */
98
- const clientSurface = `${userImports}\n${body}`
99
- const isReferenced = (entry: { name: string; specifier: string }): boolean => {
100
- if (entry.name === 'mount' || entry.name === 'hydrate') {
101
- return true
102
- }
103
- /* The SSR render always brackets itself with a per-render scope (compileSSR),
104
- so these are referenced whenever the `render` export is — i.e. always. */
105
- if (entry.name === 'enterScope' || entry.name === 'exitScope') {
106
- return true
107
- }
108
- /* Render-pass helpers are emitted by both back-ends (e.g. client and server
109
- await/try blocks both call nextBlockId); their names are distinctive enough
110
- that scanning both surfaces adds no false match. */
111
- const surface = entry.specifier.startsWith('ui/runtime/')
112
- ? `${clientSurface}\n${ssrBody}`
113
- : clientSurface
114
- return new RegExp(`\\b${entry.name}\\b`).test(surface)
115
- }
116
- const importBlock = UI_RUNTIME_IMPORTS.filter(isReferenced)
87
+ /* Per-component dead-import elimination: emit only the runtime helpers this module
88
+ references. A component using no `each`/`await`/`html` shouldn't drag those modules
89
+ into its chunk, and the package isn't globally side-effect-free (the dev/runtime
90
+ entries register globals), so a bundler can't tree-shake them for us.
91
+ Which to keep: tokenize the generated body and keep the names it genuinely
92
+ references. Reading the ACTUAL output (not hand-tracking emit sites) is safe by
93
+ construction it can never drop a needed import, the way a missed emit-site tag
94
+ could. Tokenizing rather than substring-matching (`\bname\b`) means a name inside a
95
+ string/comment/HTML literal (e.g. an `on`-attribute in static markup) no longer
96
+ forces a spurious import, so no per-surface scoping is needed: a client-only helper
97
+ simply doesn't appear as an identifier in the SSR body. */
98
+ const referenced = collectIdentifiers(`${userImports}\n${body}\n${ssrBody}\n${moduleBody}`)
99
+ const importBlock = UI_RUNTIME_IMPORTS.filter((entry) => referenced.has(entry.name))
117
100
  .map((entry) => `import { ${entry.name} } from '${ABIDE_PACKAGE_NAME}/${entry.specifier}'`)
118
101
  .join('\n')
119
- return `${importBlock}
102
+ const module = `${importBlock}
120
103
  ${userImports}
121
104
 
122
105
  ${moduleBody}`
106
+ /* Fail-loud over the WHOLE module, not just the script: the `generateBuild` /
107
+ `generateSSR` back-ends emit the build and render bodies as string-codegen, the
108
+ largest un-typed surface in the pipeline. A corruption there (a bad emit on a
109
+ template shape no test exercises) otherwise ships as a broken bundle; this surfaces
110
+ it as a located compile error for every component. */
111
+ assertTranspiles(module, 'component module generation')
112
+ /* `assertTranspiles` only proves the output PARSES — a call to an un-imported helper is
113
+ valid syntax, so it slips through. This second guard proves the output is BOUND: every
114
+ runtime helper it calls is actually imported (an independent check of the dead-import
115
+ filter above), turning a runtime `ReferenceError` into a located compile error. */
116
+ assertRuntimeHelpersBound(module, 'component module generation')
117
+ return module
123
118
  }
124
119
 
125
120
  /* Indents a body block for embedding inside a wrapper function. Lines whose start
@@ -152,3 +147,30 @@ function unescapedBacktickCount(line: string): number {
152
147
  }
153
148
  return count
154
149
  }
150
+
151
+ /* The identifier names a generated module references — every Identifier node, walked
152
+ from the real output's AST so string / comment / HTML-literal contents are excluded.
153
+ Used to decide which runtime helpers to import; reading the output (vs hand-tracking
154
+ emit sites) can never drop a needed import. A full parse (not a raw token scan) is what
155
+ keeps template literals honest: a `${…}` substitution — e.g. `navigate(`/p?ts=${Date.now()}`)`
156
+ in a handler — leaves the scanner unable to find the substitution's closing `}` without
157
+ the parser's re-scan, so a token-only pass mis-reads the rest of the module as template
158
+ text and drops every helper referenced after it (an `import`-less `effect`/`mountChild`
159
+ → a `ReferenceError` at mount → the router's reload fallback → a refresh loop). */
160
+ function collectIdentifiers(code: string): Set<string> {
161
+ const source = ts.createSourceFile(
162
+ 'module.ts',
163
+ code,
164
+ ts.ScriptTarget.Latest,
165
+ /* setParentNodes */ false,
166
+ )
167
+ const names = new Set<string>()
168
+ const visit = (node: ts.Node): void => {
169
+ if (ts.isIdentifier(node)) {
170
+ names.add(node.text)
171
+ }
172
+ node.forEachChild(visit)
173
+ }
174
+ visit(source)
175
+ return names
176
+ }
@@ -1,7 +1,6 @@
1
1
  import { analyzeComponent } from './analyzeComponent.ts'
2
2
  import { generateSSR } from './generateSSR.ts'
3
3
  import { SSR_ESCAPE } from './SSR_ESCAPE.ts'
4
- import { stripEffects } from './stripEffects.ts'
5
4
  import type { AnalyzedComponent } from './types/AnalyzedComponent.ts'
6
5
 
7
6
  /*
@@ -44,8 +43,7 @@ export function compileSSR(
44
43
  scopeSeed?: string,
45
44
  analyzed: AnalyzedComponent = analyzeComponent(source, scopeSeed),
46
45
  ): string {
47
- const { script, stateNames, derivedNames, computedNames, nodes } = analyzed
48
- const lowered = stripEffects(script)
46
+ const { ssrScript: lowered, stateNames, derivedNames, computedNames, nodes } = analyzed
49
47
  const ssr = generateSSR(nodes, stateNames, derivedNames, computedNames, isLayout)
50
48
  /* No `<style>` in the markup — the scoped CSS is bundled into the entry stylesheet
51
49
  the shell links (see `abideUiPlugin`), so SSR output is styled by that sheet. The
@@ -381,19 +381,26 @@ function emitNode(node: TemplateNode, builder: Builder): void {
381
381
  narrowing — emitting it inside the `if` block (as a plain child) instead
382
382
  gave it the positive narrowing, so a literal-union compare read as a
383
383
  "no overlap" and a typeof-narrowed branch saw the wrong member. */
384
- const elseChild = node.children.find(
385
- (child): child is Extract<TemplateNode, { kind: 'case' }> =>
386
- child.kind === 'case' && child.match === undefined,
384
+ const branches = node.children.filter(
385
+ (child): child is Extract<TemplateNode, { kind: 'case' }> => child.kind === 'case',
387
386
  )
388
- const thenChildren = node.children.filter((child) => child !== elseChild)
387
+ const thenChildren = node.children.filter((child) => child.kind !== 'case')
389
388
  builder.raw('if ')
390
389
  builder.expr(node.condition, node.loc)
391
390
  builder.raw(' {\n')
392
391
  emitNodes(thenChildren, builder)
393
392
  builder.raw('}')
394
- if (elseChild !== undefined) {
395
- builder.raw(' else {\n')
396
- emitNodes(elseChild.children, builder)
393
+ /* `elseif` → a real `else if` so its body inherits the prior conditions' negative
394
+ narrowing plus its own positive; `else` → the trailing block. */
395
+ for (const branch of branches) {
396
+ if (branch.condition !== undefined) {
397
+ builder.raw(' else if ')
398
+ builder.expr(branch.condition, branch.loc)
399
+ builder.raw(' {\n')
400
+ } else {
401
+ builder.raw(' else {\n')
402
+ }
403
+ emitNodes(branch.children, builder)
397
404
  builder.raw('}')
398
405
  }
399
406
  builder.raw('\n')