@abide/abide 0.43.1 → 0.44.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 (206) hide show
  1. package/AGENTS.md +293 -219
  2. package/CHANGELOG.md +63 -0
  3. package/README.md +191 -67
  4. package/package.json +2 -2
  5. package/src/abideLsp.ts +8 -4
  6. package/src/abideResolverPlugin.ts +17 -2
  7. package/src/build.ts +9 -0
  8. package/src/buildDisconnected.ts +2 -0
  9. package/src/devEntry.ts +44 -9
  10. package/src/lib/bundle/probeAbideServer.ts +1 -1
  11. package/src/lib/cli/parseArgvForRpc.ts +25 -26
  12. package/src/lib/cli/runCli.ts +9 -24
  13. package/src/lib/cli/tokenizeArgvFlags.ts +84 -0
  14. package/src/lib/cli/types/CliManifest.ts +6 -4
  15. package/src/lib/mcp/annotationsForMethod.ts +1 -1
  16. package/src/lib/mcp/toolResultFromResponse.ts +1 -1
  17. package/src/lib/mcp/types/JsonRpcRequest.ts +3 -3
  18. package/src/lib/server/AppModule.ts +1 -1
  19. package/src/lib/server/cli/handleCliDownload.ts +7 -5
  20. package/src/lib/server/cli/installScript.ts +3 -3
  21. package/src/lib/server/prompts/promptRegistry.ts +1 -1
  22. package/src/lib/server/prompts/types/Prompt.ts +2 -1
  23. package/src/lib/server/rpc/defineRpc.ts +9 -1
  24. package/src/lib/server/rpc/dispatchRpcInProcess.ts +2 -2
  25. package/src/lib/server/rpc/types/RpcHelper.ts +1 -1
  26. package/src/lib/server/runtime/createPublicAssetServer.ts +2 -2
  27. package/src/lib/server/runtime/createRouteDispatcher.ts +1 -1
  28. package/src/lib/server/runtime/createServer.ts +50 -12
  29. package/src/lib/server/runtime/createUiPageRenderer.ts +7 -11
  30. package/src/lib/server/runtime/crossOriginForbidden.ts +2 -2
  31. package/src/lib/server/runtime/crossOriginGate.ts +1 -1
  32. package/src/lib/server/runtime/devHotModuleResponse.ts +3 -3
  33. package/src/lib/server/runtime/disableIdleTimeoutForStream.ts +1 -1
  34. package/src/lib/server/runtime/gzipResponse.ts +14 -16
  35. package/src/lib/server/runtime/logExposedSurfaces.ts +3 -4
  36. package/src/lib/server/runtime/runWithRequestScope.ts +4 -1
  37. package/src/lib/server/runtime/serializeCacheSnapshot.ts +3 -8
  38. package/src/lib/server/runtime/snapshotEntryFromCache.ts +10 -7
  39. package/src/lib/server/runtime/streamFromIterator.ts +2 -2
  40. package/src/lib/server/runtime/types/InspectorRpc.ts +2 -2
  41. package/src/lib/server/runtime/types/RequestStore.ts +16 -0
  42. package/src/lib/server/runtime/withResponseDefaults.ts +8 -3
  43. package/src/lib/server/socket.ts +1 -1
  44. package/src/lib/server/sockets/createSocketDispatcher.ts +2 -2
  45. package/src/lib/server/sockets/defineSocket.ts +5 -1
  46. package/src/lib/server/sockets/socketRegistry.ts +2 -2
  47. package/src/lib/server/sockets/types/SocketClientFrame.ts +3 -3
  48. package/src/lib/shared/CACHE_WRAPPED.ts +3 -2
  49. package/src/lib/shared/REMOTE_FUNCTION.ts +1 -1
  50. package/src/lib/shared/RPC_ARGS_TYPE.ts +7 -0
  51. package/src/lib/shared/STREAMING_CONTENT_TYPES.ts +3 -2
  52. package/src/lib/shared/activeCacheStore.ts +5 -14
  53. package/src/lib/shared/activePage.ts +5 -19
  54. package/src/lib/shared/augmentModule.ts +18 -0
  55. package/src/lib/shared/basePath.ts +6 -5
  56. package/src/lib/shared/baseResolver.ts +10 -0
  57. package/src/lib/shared/baseSlot.ts +6 -12
  58. package/src/lib/shared/bodyValueForKind.ts +35 -0
  59. package/src/lib/shared/cache.ts +22 -2
  60. package/src/lib/shared/cacheEntryFromSnapshot.ts +23 -18
  61. package/src/lib/shared/cacheStoreResolver.ts +12 -0
  62. package/src/lib/shared/cacheStoreSlot.ts +5 -13
  63. package/src/lib/shared/changeAffectsClient.ts +35 -0
  64. package/src/lib/shared/createPushIterator.ts +1 -1
  65. package/src/lib/shared/createResolverSlot.ts +37 -0
  66. package/src/lib/shared/debugGate.ts +90 -0
  67. package/src/lib/shared/decodeResponse.ts +11 -8
  68. package/src/lib/shared/emitLogRecord.ts +18 -4
  69. package/src/lib/shared/globalCacheStoreResolver.ts +12 -0
  70. package/src/lib/shared/globalCacheStoreSlot.ts +6 -11
  71. package/src/lib/shared/hasReplayableRequest.ts +17 -0
  72. package/src/lib/shared/html.ts +7 -26
  73. package/src/lib/shared/isDebugEnabled.ts +3 -10
  74. package/src/lib/shared/isDebugNegated.ts +4 -6
  75. package/src/lib/shared/manifestModule.ts +2 -2
  76. package/src/lib/shared/online.ts +1 -1
  77. package/src/lib/shared/outboxProbeSlot.ts +1 -1
  78. package/src/lib/shared/pageResolver.ts +16 -0
  79. package/src/lib/shared/pageSlot.ts +5 -14
  80. package/src/lib/shared/pageUrlForFile.ts +1 -1
  81. package/src/lib/shared/requestScopeResolver.ts +12 -0
  82. package/src/lib/shared/requestScopeSlot.ts +7 -12
  83. package/src/lib/shared/responseBodyKind.ts +35 -0
  84. package/src/lib/shared/routeParamsShape.ts +20 -0
  85. package/src/lib/shared/setBaseResolver.ts +2 -4
  86. package/src/lib/shared/setCacheStoreResolver.ts +3 -5
  87. package/src/lib/shared/setGlobalCacheStoreResolver.ts +3 -5
  88. package/src/lib/shared/setPageResolver.ts +2 -5
  89. package/src/lib/shared/setRequestScopeResolver.ts +3 -5
  90. package/src/lib/shared/snapshotShippable.ts +22 -0
  91. package/src/lib/shared/types/CacheEntry.ts +1 -1
  92. package/src/lib/shared/types/CacheInvalidation.ts +3 -3
  93. package/src/lib/shared/types/ClientFlags.ts +5 -3
  94. package/src/lib/shared/types/DebugGate.ts +10 -0
  95. package/src/lib/shared/types/LastConnection.ts +1 -1
  96. package/src/lib/shared/types/ResolverSlot.ts +10 -0
  97. package/src/lib/shared/url.ts +4 -1
  98. package/src/lib/shared/withBase.ts +1 -2
  99. package/src/lib/shared/writeDts.ts +10 -3
  100. package/src/lib/shared/writePublicAssetsDts.ts +5 -9
  101. package/src/lib/shared/writeRoutesDts.ts +14 -46
  102. package/src/lib/shared/writeRpcDts.ts +13 -12
  103. package/src/lib/shared/writeTestRpcDts.ts +11 -11
  104. package/src/lib/shared/writeTestSocketsDts.ts +9 -9
  105. package/src/lib/test/createTestSocketChannel.ts +3 -3
  106. package/src/lib/ui/COMPONENT_WRAPPER_PREFIX.ts +1 -1
  107. package/src/lib/ui/compile/ABIDE_SEMANTIC_TOKENS_LEGEND.ts +40 -6
  108. package/src/lib/ui/compile/REACTIVE_CALLEES.ts +1 -1
  109. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +49 -37
  110. package/src/lib/ui/compile/abideUiPlugin.ts +10 -8
  111. package/src/lib/ui/compile/assertRuntimeHelpersBound.ts +27 -13
  112. package/src/lib/ui/compile/awaitPlan.ts +14 -2
  113. package/src/lib/ui/compile/catchBinding.ts +8 -0
  114. package/src/lib/ui/compile/classStyleMergePlan.ts +108 -0
  115. package/src/lib/ui/compile/collectAbideDiagnostics.ts +11 -0
  116. package/src/lib/ui/compile/compileComponent.ts +4 -4
  117. package/src/lib/ui/compile/compileModule.ts +56 -21
  118. package/src/lib/ui/compile/compileSSR.ts +12 -10
  119. package/src/lib/ui/compile/compileShadow.ts +163 -12
  120. package/src/lib/ui/compile/composeProps.ts +3 -3
  121. package/src/lib/ui/compile/createShadowLanguageService.ts +58 -4
  122. package/src/lib/ui/compile/createShadowProgram.ts +2 -1
  123. package/src/lib/ui/compile/createShadowScope.ts +42 -0
  124. package/src/lib/ui/compile/decodeHtmlEntities.ts +4 -0
  125. package/src/lib/ui/compile/desugarSignals.ts +12 -7
  126. package/src/lib/ui/compile/eachPlan.ts +61 -0
  127. package/src/lib/ui/compile/elementPlan.ts +55 -0
  128. package/src/lib/ui/compile/encodeSemanticTokens.ts +24 -1
  129. package/src/lib/ui/compile/generateBuild.ts +156 -127
  130. package/src/lib/ui/compile/generateSSR.ts +112 -73
  131. package/src/lib/ui/compile/ifPlan.ts +7 -1
  132. package/src/lib/ui/compile/interpolatedTemplateLiteral.ts +25 -0
  133. package/src/lib/ui/compile/isPlainIdentifier.ts +7 -0
  134. package/src/lib/ui/compile/isTextLeaf.ts +8 -7
  135. package/src/lib/ui/compile/lowerContext.ts +36 -46
  136. package/src/lib/ui/compile/lowerDocAccess.ts +2 -2
  137. package/src/lib/ui/compile/lowerScript.ts +1 -1
  138. package/src/lib/ui/compile/markupTokens.ts +313 -0
  139. package/src/lib/ui/compile/pagePropsType.ts +23 -0
  140. package/src/lib/ui/compile/parseTemplate.ts +135 -62
  141. package/src/lib/ui/compile/reactiveBinding.ts +47 -0
  142. package/src/lib/ui/compile/renameSignalRefs.ts +44 -10
  143. package/src/lib/ui/compile/skeletonable.ts +5 -5
  144. package/src/lib/ui/compile/snippetPlan.ts +28 -0
  145. package/src/lib/ui/compile/spreadExcludedNames.ts +4 -4
  146. package/src/lib/ui/compile/structuralBlockTokens.ts +3 -4
  147. package/src/lib/ui/compile/switchPlan.ts +7 -1
  148. package/src/lib/ui/compile/tryPlan.ts +8 -1
  149. package/src/lib/ui/compile/types/AnalyzedComponent.ts +2 -2
  150. package/src/lib/ui/compile/types/Binding.ts +24 -0
  151. package/src/lib/ui/compile/types/CompiledShadow.ts +13 -1
  152. package/src/lib/ui/compile/types/ElementPlan.ts +43 -0
  153. package/src/lib/ui/compile/types/ShadowKind.ts +9 -0
  154. package/src/lib/ui/compile/types/ShadowScope.ts +19 -0
  155. package/src/lib/ui/compile/types/TemplateAttr.ts +7 -0
  156. package/src/lib/ui/compile/types/TemplateNode.ts +9 -9
  157. package/src/lib/ui/compile/withBindings.ts +45 -0
  158. package/src/lib/ui/createScope.ts +2 -2
  159. package/src/lib/ui/dom/MATHML_NAMESPACE.ts +2 -2
  160. package/src/lib/ui/dom/SVG_NAMESPACE.ts +3 -2
  161. package/src/lib/ui/dom/appendText.ts +1 -1
  162. package/src/lib/ui/dom/applyResolved.ts +1 -1
  163. package/src/lib/ui/dom/buildDetachedRange.ts +4 -3
  164. package/src/lib/ui/dom/cloneStatic.ts +3 -2
  165. package/src/lib/ui/dom/each.ts +26 -7
  166. package/src/lib/ui/dom/foreignWrapperTag.ts +2 -2
  167. package/src/lib/ui/dom/mountChild.ts +8 -1
  168. package/src/lib/ui/dom/mountSwappableRange.ts +79 -0
  169. package/src/lib/ui/dom/readCall.ts +6 -4
  170. package/src/lib/ui/dom/scopeLabel.ts +3 -3
  171. package/src/lib/ui/dom/skeleton.ts +10 -1
  172. package/src/lib/ui/dom/switchBlock.ts +17 -63
  173. package/src/lib/ui/dom/types/EachRow.ts +4 -0
  174. package/src/lib/ui/dom/types/SkeletonHoles.ts +4 -2
  175. package/src/lib/ui/dom/when.ts +11 -64
  176. package/src/lib/ui/history.ts +1 -2
  177. package/src/lib/ui/html.ts +28 -0
  178. package/src/lib/ui/installHotBridge.ts +0 -2
  179. package/src/lib/ui/renderChain.ts +16 -3
  180. package/src/lib/ui/router.ts +24 -3
  181. package/src/lib/ui/rpcOutbox/createOutboxQueue.ts +1 -1
  182. package/src/lib/ui/runtime/CHILD_PRESENT.ts +8 -0
  183. package/src/lib/ui/runtime/OUTLET_MARKER.ts +2 -2
  184. package/src/lib/ui/runtime/RANGE_MARKER.ts +1 -1
  185. package/src/lib/ui/runtime/REACTIVE_CONTEXT.ts +9 -3
  186. package/src/lib/ui/runtime/captureModelDoc.ts +1 -1
  187. package/src/lib/ui/runtime/createDoc.ts +107 -13
  188. package/src/lib/ui/runtime/createEffectNode.ts +5 -5
  189. package/src/lib/ui/runtime/flushEffects.ts +22 -9
  190. package/src/lib/ui/runtime/historyEntries.ts +1 -1
  191. package/src/lib/ui/runtime/track.ts +4 -2
  192. package/src/lib/ui/runtime/trigger.ts +34 -20
  193. package/src/lib/ui/runtime/types/Route.ts +2 -2
  194. package/src/lib/ui/runtime/types/UiComponent.ts +3 -2
  195. package/src/lib/ui/runtime/types/UiProps.ts +9 -12
  196. package/src/lib/ui/runtime/walkPath.ts +15 -3
  197. package/src/lib/ui/startClient.ts +41 -14
  198. package/src/lib/ui/state.ts +3 -3
  199. package/src/lib/ui/types/Scope.ts +3 -3
  200. package/src/serverBuildPlugins.ts +11 -5
  201. package/src/zodCjsPlugin.ts +51 -0
  202. package/template/.zed/settings.json +7 -1
  203. package/template/package.json +3 -0
  204. package/template/src/ui/pages/layout.abide +3 -5
  205. package/src/lib/shared/matchesDebugPattern.ts +0 -16
  206. package/src/lib/shared/parseDebugPatterns.ts +0 -21
@@ -1,24 +1,16 @@
1
- import { effect } from '../effect.ts'
2
- import { RANGE_CLOSE, RANGE_OPEN } from '../runtime/RANGE_MARKER.ts'
3
- import { RENDER } from '../runtime/RENDER.ts'
4
- import { scope } from '../runtime/scope.ts'
5
- import { scopeGroup } from '../runtime/scopeGroup.ts'
6
- import { fillBefore } from './fillBefore.ts'
7
- import { openMarker } from './openMarker.ts'
8
- import { replaceRange } from './replaceRange.ts'
1
+ import { mountSwappableRange } from './mountSwappableRange.ts'
9
2
  import type { SwitchCase } from './types/SwitchCase.ts'
10
3
 
11
4
  /*
12
- Multi-branch binding — the runtime for `<template switch>`. An effect evaluates the
5
+ Multi-branch binding — the runtime for `{#switch}` blocks and for `{#if}` chains that
6
+ include `{:elseif}` branches (compiled as a switch over `true` with Boolean-coerced
7
+ match thunks so the first truthy branch wins). A swappable range evaluates the
13
8
  subject, picks the first case whose `match` equals it (strict `===`), falling back
14
9
  to the default (`match` undefined); the chosen case's content lives in a RANGE
15
10
  bounded by two comment markers, so a case holds any content. Staying on the same
16
11
  case across a subject change leaves it mounted; switching clears the range and
17
- builds the new case fresh.
18
-
19
- On hydrate it adopts the case the server rendered: claim the start marker, run the
20
- matching case in place, claim the end marker. The effect's first run picks the same
21
- case and is a no-op; later changes swap the range.
12
+ builds the new case fresh. See `mountSwappableRange` for the shared
13
+ hydrate/swap/teardown semantics — the case index is the swap key.
22
14
  */
23
15
  // @documentation plumbing
24
16
  export function switchBlock(
@@ -27,59 +19,21 @@ export function switchBlock(
27
19
  cases: SwitchCase[],
28
20
  before: Node | null = null,
29
21
  ): void {
30
- const hydration = RENDER.hydration
31
- /* The live case's scope, registered with the owner so it disposes on owner
32
- teardown — not only when the subject switches cases via replaceRange. */
33
- const group = scopeGroup()
34
- let dispose: (() => void) | undefined
35
- let activeIndex: number
36
- let end: Comment
37
-
22
+ /* Pick the first case matching the subject (`===`), else the default (`match`
23
+ undefined), else -1 for no match. */
38
24
  const select = (value: unknown): number => {
39
25
  const matched = cases.findIndex(
40
26
  (entry) => entry.match !== undefined && entry.match() === value,
41
27
  )
42
28
  return matched === -1 ? cases.findIndex((entry) => entry.match === undefined) : matched
43
29
  }
44
- const caseAt = (index: number): SwitchCase | undefined =>
45
- index === -1 ? undefined : cases[index]
46
- /* The chosen case builds through `scope` (directly on hydrate, via `fillBefore` on a
47
- swap), which builds untracked — so a raw reactive read in the case content doesn't
48
- subscribe the swap effect below; only `subject()` (and each case's `match()`) drives
49
- the swap. The case's own interpolations still track, each through its own effect. */
50
-
51
- /* `before` places the range among static siblings on create (block before a suffix);
52
- hydrate ignores it and uses the parked claim cursor. */
53
- const start = openMarker(parent, RANGE_OPEN, before)
54
- if (hydration !== undefined) {
55
- activeIndex = select(subject())
56
- const chosen = caseAt(activeIndex)
57
- if (chosen !== undefined) {
58
- dispose = group.track(scope(() => chosen.render(parent))) // claim the SSR nodes in place
59
- }
60
- end = openMarker(parent, RANGE_CLOSE)
61
- } else {
62
- end = openMarker(parent, RANGE_CLOSE, before)
63
- activeIndex = select(subject())
64
- const chosen = caseAt(activeIndex)
65
- if (chosen !== undefined) {
66
- dispose = group.track(fillBefore(end, (p) => chosen.render(p)))
67
- }
68
- }
69
-
70
- effect(() => {
71
- const index = select(subject())
72
- if (index === activeIndex) {
73
- return
74
- }
75
- activeIndex = index
76
- const chosen = caseAt(index)
77
- /* Null `dispose` before `replaceRange` builds the new case: a reentrant switch
78
- during that build (an effect in the new content writing the subject) would
79
- otherwise re-enter with the already-disposed disposer and clear it twice. */
80
- const prior = dispose
81
- dispose = undefined
82
- const next = replaceRange(start, end, prior, chosen && ((p) => chosen.render(p)))
83
- dispose = next !== undefined ? group.track(next) : undefined
84
- })
30
+ mountSwappableRange(
31
+ parent,
32
+ () => select(subject()),
33
+ (index) => {
34
+ const chosen = index === -1 ? undefined : cases[index]
35
+ return chosen && ((p) => chosen.render(p))
36
+ },
37
+ before,
38
+ )
85
39
  }
@@ -14,4 +14,8 @@ export type EachRow = {
14
14
  cell: State<unknown>
15
15
  indexCell: State<number>
16
16
  pending?: DocumentFragment
17
+ /* The id of the last reconcile pass that saw this row's key in the source. A row
18
+ whose `gen` lags the current pass departed and is pruned — an int compare that
19
+ replaces a per-reconcile `Set` of present keys. */
20
+ gen?: number
17
21
  }
@@ -1,7 +1,9 @@
1
1
  /* The holes a realized `skeleton` exposes for the build's attach code to wire up:
2
2
  `el` the element holes (attribute/listener/bind nodes) in pre-order, `an` the anchor
3
- holes (reactive text, control flow, components) in document order. Two arrays so the
4
- compiler and SSR only agree on traversal order, never on synchronized indices. */
3
+ holes (reactive text, control flow, components) in document order. Two arrays because
4
+ the element and anchor walks follow different traversal rules (`walkElementOrder` vs
5
+ `walkAnchorOrder`), so their indices are independent series — el[N] and an[N] are
6
+ unrelated holes, each numbered from 0 by its own walk. */
5
7
  export type SkeletonHoles = {
6
8
  el: Element[]
7
9
  an: Node[]
@@ -1,24 +1,13 @@
1
- import { effect } from '../effect.ts'
2
- import { RANGE_CLOSE, RANGE_OPEN } from '../runtime/RANGE_MARKER.ts'
3
- import { RENDER } from '../runtime/RENDER.ts'
4
- import { scope } from '../runtime/scope.ts'
5
- import { scopeGroup } from '../runtime/scopeGroup.ts'
6
- import { fillBefore } from './fillBefore.ts'
7
- import { openMarker } from './openMarker.ts'
8
- import { replaceRange } from './replaceRange.ts'
1
+ import { mountSwappableRange } from './mountSwappableRange.ts'
9
2
 
10
3
  /*
11
4
  Conditional binding — the runtime for `<template if>` (with optional `else`). The
12
5
  branch's content lives in a RANGE bounded by two comment markers, so a branch may
13
6
  hold anything — elements, components, text, nested control-flow, snippets — not
14
- just element roots. An effect tracks `condition()` and swaps the range's content
15
- on a truthy↔falsy flip (`render` truthy, `renderElse` falsy); an unchanged
16
- condition is a no-op.
17
-
18
- On hydrate it adopts the server-rendered range: claim the start marker, run the
19
- matching render in place (its content claims the existing nodes), then claim the
20
- end marker. The effect's first run sees the same branch and is a no-op; later
21
- toggles clear the range and build fresh into a fragment.
7
+ just element roots. A 2-case swappable range tracks `condition()` and swaps the
8
+ range's content on a truthy↔falsy flip (`render` truthy, `renderElse` falsy); an
9
+ unchanged condition is a no-op. See `mountSwappableRange` for the shared
10
+ hydrate/swap/teardown semantics.
22
11
  */
23
12
  // @documentation plumbing
24
13
  export function when(
@@ -28,52 +17,10 @@ export function when(
28
17
  renderElse?: (parent: Node) => void,
29
18
  before: Node | null = null,
30
19
  ): void {
31
- const hydration = RENDER.hydration
32
- /* The chosen branch builds through `scope` (directly on hydrate, via `fillBefore`
33
- on a swap), which builds untracked — so a raw reactive read in the branch content
34
- doesn't subscribe the swap effect below; only `condition()` drives the toggle. The
35
- branch's own interpolations still track, each through its own effect. */
36
- const chosenFor = (branch: 'then' | 'else') => (branch === 'then' ? render : renderElse)
37
- /* The live branch's scope, registered with the owner so it disposes on owner
38
- teardown — not only on a branch flip via replaceRange. */
39
- const group = scopeGroup()
40
- let dispose: (() => void) | undefined
41
- let activeBranch: 'then' | 'else'
42
- let end: Comment
43
-
44
- /* `before` (a static node located by the skeleton) places the range among siblings on
45
- create, so the block sits before a static suffix rather than at the parent's end.
46
- Hydrate ignores it — the claim cursor (positioned past the prefix) drives placement. */
47
- const start = openMarker(parent, RANGE_OPEN, before)
48
- if (hydration !== undefined) {
49
- activeBranch = condition() ? 'then' : 'else'
50
- const chosen = chosenFor(activeBranch)
51
- if (chosen !== undefined) {
52
- dispose = group.track(scope(() => chosen(parent))) // content claims the SSR nodes in place
53
- }
54
- end = openMarker(parent, RANGE_CLOSE)
55
- } else {
56
- end = openMarker(parent, RANGE_CLOSE, before)
57
- activeBranch = condition() ? 'then' : 'else'
58
- const chosen = chosenFor(activeBranch)
59
- if (chosen !== undefined) {
60
- dispose = group.track(fillBefore(end, chosen))
61
- }
62
- }
63
-
64
- effect(() => {
65
- const branch = condition() ? 'then' : 'else'
66
- if (branch === activeBranch) {
67
- return
68
- }
69
- activeBranch = branch
70
- const chosen = chosenFor(branch)
71
- /* Null `dispose` before `replaceRange` builds the new branch: a reentrant flip
72
- during that build (an effect in the new content writing the condition) would
73
- otherwise re-enter with the already-disposed disposer and clear it twice. */
74
- const prior = dispose
75
- dispose = undefined
76
- const next = replaceRange(start, end, prior, chosen)
77
- dispose = next !== undefined ? group.track(next) : undefined
78
- })
20
+ mountSwappableRange(
21
+ parent,
22
+ () => (condition() ? 'then' : 'else'),
23
+ (branch) => (branch === 'then' ? render : renderElse),
24
+ before,
25
+ )
79
26
  }
@@ -21,8 +21,7 @@ export function history(doc: Doc, { limit = 100 }: { limit?: number } = {}): His
21
21
  — held by an in-progress transaction and by an undo/redo replay. */
22
22
  let sink: Patch[] | undefined
23
23
 
24
- /* Push one finished entry, evicting the oldest past `limit`; a fresh edit (no
25
- sink) also invalidates the redo stack. */
24
+ /* Push one finished entry, evicting the oldest past `limit`. */
26
25
  const push = (stack: Patch[][], entry: Patch[]): void => {
27
26
  stack.push(entry)
28
27
  if (stack.length > limit) {
@@ -0,0 +1,28 @@
1
+ import { RAW_HTML, type RawHtml } from '../shared/html.ts'
2
+
3
+ /*
4
+ Marks a string as trusted raw HTML so a `{expr}` interpolation inserts its nodes
5
+ instead of escaped text — the abide idiom for raw markup (no `{@html}` mustache).
6
+ Works two ways:
7
+
8
+ html(trustedString) // plain call — insert the string verbatim
9
+ html`<b>${name}</b>` // tagged — concatenate parts verbatim
10
+
11
+ Calling `html` is the explicit opt-in to raw insertion; plain `{value}` always
12
+ escapes. The tag does NOT auto-escape interpolations (it's raw by intent), so only
13
+ build markup from values you trust, or escape them yourself. Imported by the author
14
+ (`import { html } from 'abide/ui/html'`) — it is UI-authoring vocabulary, only ever
15
+ written inside a template, so it lives in `ui/` (the reader, `rawHtmlString`, is the
16
+ isomorphic plumbing and stays in `shared/`).
17
+ */
18
+ // @documentation templating
19
+ export function html(strings: TemplateStringsArray | string, ...values: unknown[]): RawHtml {
20
+ if (typeof strings === 'string') {
21
+ return { [RAW_HTML]: strings }
22
+ }
23
+ let markup = strings[0] ?? ''
24
+ for (let index = 0; index < values.length; index += 1) {
25
+ markup += String(values[index]) + (strings[index + 1] ?? '')
26
+ }
27
+ return { [RAW_HTML]: markup }
28
+ }
@@ -1,4 +1,3 @@
1
- import { html } from '../shared/html.ts'
2
1
  import { snippet } from '../shared/snippet.ts'
3
2
  import { anchorCursor } from './dom/anchorCursor.ts'
4
3
  import { appendSnippet } from './dom/appendSnippet.ts'
@@ -49,7 +48,6 @@ dev (the live-reload script sets `window.__abideDev`).
49
48
  */
50
49
  export function installHotBridge(): void {
51
50
  ;(globalThis as { __abide?: Record<string, unknown> }).__abide = {
52
- html,
53
51
  snippet,
54
52
  scope,
55
53
  enterScope,
@@ -1,7 +1,9 @@
1
+ import { CHILD_PRESENT } from './runtime/CHILD_PRESENT.ts'
1
2
  import { OUTLET_CLOSE, OUTLET_OPEN } from './runtime/OUTLET_MARKER.ts'
2
3
  import type { RenderContext } from './runtime/types/RenderContext.ts'
3
4
  import type { SsrRender } from './runtime/types/SsrRender.ts'
4
5
  import type { UiComponent } from './runtime/types/UiComponent.ts'
6
+ import type { UiProps } from './runtime/types/UiProps.ts'
5
7
 
6
8
  const OPEN = `<!--${OUTLET_OPEN}-->`
7
9
  const CLOSE = `<!--${OUTLET_CLOSE}-->`
@@ -31,8 +33,19 @@ export async function renderChain(
31
33
  ): Promise<SsrRender> {
32
34
  const ctx: RenderContext = { next: 0 }
33
35
  const renders: SsrRender[] = []
34
- for (const view of views) {
35
- renders.push(await view.render(params, ctx))
36
+ /* Route params as thunks (static server-side only shape parity with the client so
37
+ `props()` reads `$props["id"]?.()` resolve). A layout (every view but the last) also
38
+ gets `$children` set to `CHILD_PRESENT` so SSR renders `{#if children}` the same way
39
+ the client does, keeping hydration congruent. */
40
+ const paramThunks: UiProps = {}
41
+ for (const key of Object.keys(params)) {
42
+ paramThunks[key] = () => params[key]
43
+ }
44
+ for (let index = 0; index < views.length; index += 1) {
45
+ const view = views[index] as UiComponent
46
+ const hasChild = index < views.length - 1
47
+ const props: UiProps = hasChild ? { ...paramThunks, $children: CHILD_PRESENT } : paramThunks
48
+ renders.push(await view.render(props, ctx))
36
49
  }
37
50
  let html = renders[renders.length - 1]?.html ?? ''
38
51
  for (let index = renders.length - 2; index >= 0; index -= 1) {
@@ -42,7 +55,7 @@ export async function renderChain(
42
55
  so a second outlet would mount the SSR child and the hydrated child into
43
56
  DIFFERENT slots — a silent desync. Throw at build instead. */
44
57
  if (parent.html.split(OUTLET_PLACEHOLDER).length - 1 !== 1) {
45
- throw new Error('[abide] a layout.abide must contain exactly one <slot/> outlet')
58
+ throw new Error('[abide] a layout.abide must contain exactly one {children()} outlet')
46
59
  }
47
60
  /* Fold the child between the outlet markers (function replacement so `$&`/`$\``
48
61
  in the child html insert literally). */
@@ -4,6 +4,7 @@ import { outlet } from './dom/outlet.ts'
4
4
  import { effect } from './effect.ts'
5
5
  import { matchRoute } from './matchRoute.ts'
6
6
  import { navigatePath } from './navigate.ts'
7
+ import { CHILD_PRESENT } from './runtime/CHILD_PRESENT.ts'
7
8
  import { clientPage } from './runtime/clientPage.ts'
8
9
  import { enterRenderPass } from './runtime/enterRenderPass.ts'
9
10
  import { exitRenderPass } from './runtime/exitRenderPass.ts'
@@ -15,6 +16,7 @@ import type { AbideHistoryState } from './runtime/types/AbideHistoryState.ts'
15
16
  import type { NavVerdict } from './runtime/types/NavVerdict.ts'
16
17
  import type { Route } from './runtime/types/Route.ts'
17
18
  import type { RouteLoader } from './runtime/types/RouteLoader.ts'
19
+ import type { UiProps } from './runtime/types/UiProps.ts'
18
20
  import { untrack } from './runtime/untrack.ts'
19
21
 
20
22
  /* An outlet boundary — the `<!--abide:outlet-->`…`<!--/abide:outlet-->` marker pair a
@@ -192,6 +194,23 @@ export function router(
192
194
  outlet(host)
193
195
  rootBoundary = PENDING_OUTLET.current as Boundary
194
196
  }
197
+ /* Route params as reactive thunks: reading `clientPage.value.params` inside the
198
+ thunk tracks the page signal, so an in-place same-route hop (params change, the
199
+ page stays mounted) re-runs each `props()` derive. The key set is stable across
200
+ same-route hops, so the bag need not rebuild. A layout also gets `$children`
201
+ set to `CHILD_PRESENT` when a child layer exists below it, read by
202
+ `{#if children}` (a layout's `{children()}` lowers to its `outlet()` boundary,
203
+ so it ignores this value). */
204
+ const propsBag = (hasChild: boolean): UiProps => {
205
+ const bag: UiProps = {}
206
+ for (const key of Object.keys(params)) {
207
+ bag[key] = () => clientPage.value.params[key]
208
+ }
209
+ if (hasChild) {
210
+ bag.$children = CHILD_PRESENT
211
+ }
212
+ return bag
213
+ }
195
214
  let boundary = baseBoundary(index)
196
215
  for (let depth = index; depth < layoutViews.length; depth += 1) {
197
216
  const view = layoutViews[depth] as Route
@@ -200,14 +219,15 @@ export function router(
200
219
  boundary.open,
201
220
  boundary.close,
202
221
  view.build,
203
- params,
222
+ /* A layout always has a child below: a deeper layout, or the page. */
223
+ propsBag(depth < layoutViews.length - 1 || pageView !== undefined),
204
224
  /* The layout's route key names its scope in the inspector's Reactive tab
205
225
  (no host element to read a tag from — see `scopeLabel`). */
206
226
  chainKeys[depth],
207
227
  )
208
228
  const slot = PENDING_OUTLET.current
209
229
  if (slot === undefined) {
210
- throw new Error('[abide] a layout.abide must contain a <slot/> outlet')
230
+ throw new Error('[abide] a layout.abide must contain a {children()} outlet')
211
231
  }
212
232
  mountedLayouts.push({ key: chainKeys[depth] as string, dispose, slot })
213
233
  boundary = slot
@@ -220,7 +240,8 @@ export function router(
220
240
  boundary.open,
221
241
  boundary.close,
222
242
  pageView.build,
223
- params,
243
+ /* A page is a leaf — no child layer. */
244
+ propsBag(false),
224
245
  /* The page's route key names its scope in the inspector (see above). */
225
246
  pageKey,
226
247
  ).dispose
@@ -41,7 +41,7 @@ const keyFor = (url: string): string => `abide:outbox:${url}`
41
41
  /*
42
42
  A durable, app-owned FIFO retry queue for one RPC. A call fetches directly and throws as
43
43
  before while the queue is empty; when the server can't be reached — a transport failure or
44
- a 502/503/504/52x — the caller `park`s the request here (a side-effect; the call still
44
+ a 502/503/504/520–527/530 — the caller `park`s the request here (a side-effect; the call still
45
45
  throws), and once a backlog exists the caller parks every later call at the tail too, so
46
46
  FIFO order is preserved on replay. Built on `doc` + `persist`, so the queue IS a persisted
47
47
  document that survives a reload. Draining is manual — the app calls `retry()` (per-entry or
@@ -0,0 +1,8 @@
1
+ /*
2
+ A no-op truthiness marker the router (client) and `renderChain` (SSR) set as a layout's
3
+ `$props.$children` when a child layer (a nested layout or the page) exists below it. Read
4
+ only by `{#if children}` (→ `$props?.$children`); a layout's `{children()}` lowers to the
5
+ `outlet()` boundary the router fills, so this value is never invoked — it exists purely so
6
+ `{#if children}` reads a uniform presence signal on both the client and the server.
7
+ */
8
+ export const CHILD_PRESENT = (_host: Element): void => {}
@@ -2,8 +2,8 @@
2
2
  `<abide-outlet>` ELEMENT, so the next chain layer the router fills in lays out as a true
3
3
  direct child of the slot's parent (no wrapper box breaking the layout's flex/grid/`:first-child`).
4
4
 
5
- `abide:outlet` / `/abide:outlet` deliberately match `skeleton`'s `isOpenMarker`/`isCloseMarker`
6
- (the `abide:` / `/abide:` convention), so a layout's own hole-scanning treats the outlet's
5
+ `abide:outlet` / `/abide:outlet` deliberately match `markerDepthDelta`'s `startsWith('abide:')` /
6
+ `startsWith('/abide:')` rule (the `abide:` / `/abide:` convention), so a layout's own hole-scanning treats the outlet's
7
7
  future child content as a balanced range and skips it — exactly like an `await`/`try` boundary.
8
8
  The router fills the boundary with the next layer (see `outlet`/`fillBoundary`). */
9
9
  export const OUTLET_OPEN = 'abide:outlet'
@@ -1,6 +1,6 @@
1
1
  /* The comment-marker "wire alphabet" — the single source of truth for the sentinel
2
2
  strings the SSR emit (`generateSSR`) writes into HTML comments and the hydrate scan
3
- (`skeleton`) + every range-mount runtime (`when`/`switch`/`each`/`mountRange`/
3
+ (`skeleton`) + every range-mount runtime (`when`/`switchBlock`/`each`/`mountRange`/
4
4
  `mountSlot`/`appendSnippet`) creates as `document.createComment` nodes. Both sides
5
5
  reference THESE constants, so a marker the server writes and the marker the client
6
6
  looks for can never drift on a literal.
@@ -2,13 +2,19 @@ import type { ReactiveNode } from './types/ReactiveNode.ts'
2
2
 
3
3
  /*
4
4
  Mutable singletons shared by every reactive primitive: the observer currently
5
- running (so a read can register itself as that observer's dependency), the set
5
+ running (so a read can register itself as that observer's dependency), the queue
6
6
  of effects dirtied since the last flush, and the batch depth (writes inside a
7
7
  batch queue effects and flush once on exit). Held on one object so signal,
8
8
  computed, and effect all reference the same graph state without a barrel.
9
+
10
+ `pendingEffects` is a plain array, not a `Set`: an effect is enqueued only on its
11
+ CLEAN→dirty transition (`mark`'s `wasClean` gate), so it can be pushed at most once
12
+ per flush cycle — the status machine already guarantees the dedup a `Set` would,
13
+ without hashing every enqueue. The flush double-buffers (swaps in a fresh array) so
14
+ effects re-dirtied mid-flush queue for the next pass.
9
15
  */
10
16
  export const REACTIVE_CONTEXT: {
11
17
  observer: ReactiveNode | undefined
12
- pendingEffects: Set<ReactiveNode>
18
+ pendingEffects: ReactiveNode[]
13
19
  batchDepth: number
14
- } = { observer: undefined, pendingEffects: new Set(), batchDepth: 0 }
20
+ } = { observer: undefined, pendingEffects: [], batchDepth: 0 }
@@ -6,7 +6,7 @@ Runs a component `build` and returns its result (the mount handle / disposer)
6
6
  alongside the component's own `model` document — the serializable `state` doc,
7
7
  needed so a hot swap can carry its value across (see `hotReplace`). The model is
8
8
  found, not threaded: a component seeds its `model` first (the desugared
9
- `const model = doc({})` + its init patches run before any child mounts or
9
+ `const $$model = scope()` + its init `replace` patches (state slots lowered by `lowerDocAccess`) run before any child mounts or
10
10
  control-flow blocks), so the FIRST patch announced on the bus during the build names
11
11
  it. A component with no `state` mints no model and emits nothing first — `model` is
12
12
  then `undefined` and there is nothing to preserve. Used only on the hot path; the
@@ -43,6 +43,18 @@ the change.
43
43
  export function createDoc(initial: unknown): Doc {
44
44
  let tree = initial
45
45
  const nodes = new Map<string, ReactiveNode>()
46
+ /* Prefix index: every path → the set of its DIRECT children that lie on a route
47
+ to some minted node (a trie over the minted paths' ancestor chains). A
48
+ structural descend walks this from the change root through actual descendants
49
+ only, replacing the old O(all live cells) `startsWith` scan over the whole
50
+ `nodes` map. The links span INTERMEDIATE paths that have no node of their own
51
+ (e.g. `byId` and `byId/<key>` exist as links even when only `byId/<key>/n` was
52
+ read), so a descend from any ancestor reaches the leaf. Maintained in lockstep
53
+ with `nodes`: a node's whole ancestor chain is linked when `nodeFor` mints it,
54
+ unlinked from the leaf up when `wakeSubtree` evicts it. Membership of a path in
55
+ `nodes` (does it carry a signal?) is independent of its membership here (is it
56
+ on a route to one?). */
57
+ const childKeys = new Map<string, Set<string>>()
46
58
  /* Computed slots: a path whose value is a function of other paths, not stored
47
59
  truth. Held apart from `nodes` so the structural wake/eviction never touches
48
60
  them; their dirtiness is driven entirely by the deps they read (the signal
@@ -54,15 +66,65 @@ export function createDoc(initial: unknown): Doc {
54
66
  name the document it came from (reference identity, the undo/persistence key). */
55
67
  let self: Doc
56
68
 
69
+ /* Links `path` into the trie under its parent and continues up the ancestor chain.
70
+ Stops at the first ancestor whose parent set already lists it — that link, and
71
+ everything above it, is already present (some sibling minted the upper chain). */
72
+ function linkPath(path: string): void {
73
+ let child = path
74
+ for (;;) {
75
+ const parentPath = parentPathOf(child)
76
+ const siblings = childKeys.get(parentPath)
77
+ if (siblings === undefined) {
78
+ childKeys.set(parentPath, new Set([child]))
79
+ } else if (siblings.has(child)) {
80
+ return
81
+ } else {
82
+ siblings.add(child)
83
+ }
84
+ if (parentPath === '') {
85
+ return
86
+ }
87
+ child = parentPath
88
+ }
89
+ }
90
+
57
91
  function nodeFor(path: string): ReactiveNode {
58
92
  let node = nodes.get(path)
59
93
  if (node === undefined) {
60
94
  node = createSignalNode(walkPath(tree, path).value)
61
95
  nodes.set(path, node)
96
+ linkPath(path)
62
97
  }
63
98
  return node
64
99
  }
65
100
 
101
+ /* Drops a node and unlinks it from the trie, pruning each emptied ancestor link up
102
+ the chain — the eviction half of the lockstep with `nodeFor`. A link is removed
103
+ only once its child set empties, so an intermediate path with other live
104
+ descendants keeps its place. */
105
+ function evict(path: string): void {
106
+ nodes.delete(path)
107
+ let child = path
108
+ for (;;) {
109
+ const parentPath = parentPathOf(child)
110
+ const siblings = childKeys.get(parentPath)
111
+ if (siblings === undefined) {
112
+ return
113
+ }
114
+ siblings.delete(child)
115
+ /* A parent still listing other children, or still carrying its own node,
116
+ stays — only a fully empty, node-less link is pruned and walked past. */
117
+ if (siblings.size > 0) {
118
+ return
119
+ }
120
+ childKeys.delete(parentPath)
121
+ if (parentPath === '' || nodes.has(parentPath)) {
122
+ return
123
+ }
124
+ child = parentPath
125
+ }
126
+ }
127
+
66
128
  function read<T>(path: string): T {
67
129
  /* Size-gated so a doc with no computed slots pays nothing on the stored hot
68
130
  path — only one `.size` check, not a `.get` per read. */
@@ -109,21 +171,53 @@ export function createDoc(initial: unknown): Doc {
109
171
  return
110
172
  }
111
173
  const prefix = rootPath === '' ? '' : `${rootPath}/`
112
- for (const [candidate, node] of nodes) {
113
- if (candidate !== rootPath && candidate.startsWith(prefix)) {
114
- /* A descendant whose path the mutation removed a deleted key, an
115
- out-of-range index after a shrink is woken to undefined, then
116
- dropped from the registry. Without eviction `nodes` grows for the
117
- life of the session over churning keys (items/<uuid>, message ids),
118
- and this very descend scan degrades linearly with it. The woken
119
- reader re-mints a fresh node on its flush if the path ever returns.
120
- Deleting the current entry mid-iteration is safe on a Map. */
121
- const walk = walkPath(tree, candidate)
174
+ /* Walk the prefix index from `rootPath` over its real descendants only — a
175
+ BFS over the parent→children adjacency map — instead of scanning every live
176
+ node. `pending` holds parent-paths whose direct children are yet to visit;
177
+ each visited child that is itself a container is enqueued. The total work is
178
+ the size of the changed subtree, not the whole doc. */
179
+ const pending: string[] = [rootPath]
180
+ while (pending.length > 0) {
181
+ const parentPath = pending.pop() as string
182
+ const siblings = childKeys.get(parentPath)
183
+ if (siblings === undefined) {
184
+ continue
185
+ }
186
+ /* Snapshot the set before iterating: eviction below mutates it, and a
187
+ deleted child must not skip its successor (Set iteration is order-
188
+ sensitive to in-place deletes). */
189
+ for (const candidate of [...siblings]) {
190
+ /* Walk from the already-resolved container (`rootValue`) using only the
191
+ path SUFFIX past the shared prefix, instead of re-walking every
192
+ candidate's full path from the tree root — the prefix is walked once
193
+ per wake, not once per descendant. */
194
+ const walk = walkPath(rootValue, candidate.slice(prefix.length))
195
+ /* An INTERMEDIATE link (a path on a route to a node but carrying none of
196
+ its own — e.g. `byId/<key>` when only `byId/<key>/n` was read) has no
197
+ signal to wake; it exists only to be descended through. So the
198
+ wake/evict touches a node only when one is present, while the descend
199
+ decision below is independent of node existence. */
200
+ const node = nodes.get(candidate)
122
201
  if (walk.exists) {
123
- writeNode(node, walk.value)
202
+ if (node !== undefined) {
203
+ writeNode(node, walk.value)
204
+ }
205
+ /* Descend into a container child to reach its own descendants. */
206
+ if (walk.value !== null && typeof walk.value === 'object') {
207
+ pending.push(candidate)
208
+ }
124
209
  } else {
125
- writeNode(node, undefined)
126
- nodes.delete(candidate)
210
+ /* A descendant whose path the mutation removed — a deleted key, an
211
+ out-of-range index after a shrink — is woken to undefined, then
212
+ dropped from the registry. The woken reader re-mints a fresh node
213
+ on its flush if the path ever returns. A removed container's own
214
+ descendants are still enqueued (whether or not THIS path held a
215
+ node) so they too are woken/evicted. */
216
+ if (node !== undefined) {
217
+ writeNode(node, undefined)
218
+ evict(candidate)
219
+ }
220
+ pending.push(candidate)
127
221
  }
128
222
  }
129
223
  }
@@ -1,6 +1,5 @@
1
1
  import { NODE_STATE } from './NODE_STATE.ts'
2
2
  import { OWNER } from './OWNER.ts'
3
- import { REACTIVE_CONTEXT } from './REACTIVE_CONTEXT.ts'
4
3
  import { runNode } from './runNode.ts'
5
4
  import { toTeardown } from './toTeardown.ts'
6
5
  import type { EffectResult } from './types/EffectResult.ts'
@@ -52,11 +51,12 @@ export function createEffectNode(fn: () => EffectResult): () => void {
52
51
  runCleanup()
53
52
  unlinkDeps(node)
54
53
  /* Clearing compute makes runNode a no-op: an effect disposed mid-flush (by an
55
- earlier effect in the same batch) is still in flushEffects' snapshot array
56
- after pendingEffects.delete, so it would otherwise re-run its body and
57
- re-link into the graph — a disposed effect resurrected. */
54
+ earlier effect in the same batch) is still in the queue flushEffects is
55
+ draining, so without this it would re-run its body and re-link into the graph
56
+ — a disposed effect resurrected. This alone neutralizes it; the queue is a
57
+ plain array (no O(1) delete) and a settled no-op iteration is cheaper than an
58
+ O(n) splice, so the stale entry is left to be skipped on its flush. */
58
59
  node.compute = undefined
59
- REACTIVE_CONTEXT.pendingEffects.delete(node)
60
60
  }
61
61
  if (OWNER.current !== undefined) {
62
62
  OWNER.current.push(dispose)