@abide/abide 0.43.0 → 0.44.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.
Files changed (160) hide show
  1. package/AGENTS.md +293 -219
  2. package/CHANGELOG.md +46 -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 +11 -1
  7. package/src/build.ts +9 -0
  8. package/src/buildDisconnected.ts +2 -0
  9. package/src/devEntry.ts +1 -0
  10. package/src/lib/bundle/probeAbideServer.ts +1 -1
  11. package/src/lib/cli/parseArgvForRpc.ts +4 -2
  12. package/src/lib/cli/types/CliManifest.ts +6 -4
  13. package/src/lib/mcp/annotationsForMethod.ts +1 -1
  14. package/src/lib/mcp/toolResultFromResponse.ts +1 -1
  15. package/src/lib/mcp/types/JsonRpcRequest.ts +3 -3
  16. package/src/lib/server/AppModule.ts +1 -1
  17. package/src/lib/server/cli/handleCliDownload.ts +7 -5
  18. package/src/lib/server/cli/installScript.ts +3 -3
  19. package/src/lib/server/prompts/promptRegistry.ts +1 -1
  20. package/src/lib/server/prompts/types/Prompt.ts +2 -1
  21. package/src/lib/server/rpc/dispatchRpcInProcess.ts +2 -2
  22. package/src/lib/server/rpc/types/RpcHelper.ts +1 -1
  23. package/src/lib/server/runtime/createPublicAssetServer.ts +2 -2
  24. package/src/lib/server/runtime/createRouteDispatcher.ts +1 -1
  25. package/src/lib/server/runtime/createServer.ts +21 -0
  26. package/src/lib/server/runtime/createUiPageRenderer.ts +7 -11
  27. package/src/lib/server/runtime/crossOriginForbidden.ts +2 -2
  28. package/src/lib/server/runtime/crossOriginGate.ts +1 -1
  29. package/src/lib/server/runtime/devHotModuleResponse.ts +2 -2
  30. package/src/lib/server/runtime/disableIdleTimeoutForStream.ts +1 -1
  31. package/src/lib/server/runtime/logExposedSurfaces.ts +3 -4
  32. package/src/lib/server/runtime/serializeCacheSnapshot.ts +3 -8
  33. package/src/lib/server/runtime/snapshotEntryFromCache.ts +10 -7
  34. package/src/lib/server/runtime/streamFromIterator.ts +2 -2
  35. package/src/lib/server/runtime/types/InspectorRpc.ts +2 -2
  36. package/src/lib/server/socket.ts +1 -1
  37. package/src/lib/server/sockets/createSocketDispatcher.ts +2 -2
  38. package/src/lib/server/sockets/socketRegistry.ts +2 -2
  39. package/src/lib/server/sockets/types/SocketClientFrame.ts +3 -3
  40. package/src/lib/shared/CACHE_WRAPPED.ts +3 -2
  41. package/src/lib/shared/REMOTE_FUNCTION.ts +1 -1
  42. package/src/lib/shared/STREAMING_CONTENT_TYPES.ts +3 -2
  43. package/src/lib/shared/bodyValueForKind.ts +35 -0
  44. package/src/lib/shared/cache.ts +1 -1
  45. package/src/lib/shared/cacheEntryFromSnapshot.ts +23 -18
  46. package/src/lib/shared/createPushIterator.ts +1 -1
  47. package/src/lib/shared/decodeResponse.ts +11 -8
  48. package/src/lib/shared/hasReplayableRequest.ts +17 -0
  49. package/src/lib/shared/html.ts +7 -26
  50. package/src/lib/shared/manifestModule.ts +2 -2
  51. package/src/lib/shared/online.ts +1 -1
  52. package/src/lib/shared/outboxProbeSlot.ts +1 -1
  53. package/src/lib/shared/pageUrlForFile.ts +1 -1
  54. package/src/lib/shared/routeParamsShape.ts +20 -0
  55. package/src/lib/shared/snapshotShippable.ts +22 -0
  56. package/src/lib/shared/types/CacheEntry.ts +1 -1
  57. package/src/lib/shared/types/CacheInvalidation.ts +3 -3
  58. package/src/lib/shared/types/ClientFlags.ts +5 -3
  59. package/src/lib/shared/types/LastConnection.ts +1 -1
  60. package/src/lib/shared/url.ts +4 -1
  61. package/src/lib/shared/withBase.ts +1 -2
  62. package/src/lib/shared/writeDts.ts +2 -2
  63. package/src/lib/shared/writeRoutesDts.ts +3 -31
  64. package/src/lib/test/createTestSocketChannel.ts +3 -3
  65. package/src/lib/ui/COMPONENT_WRAPPER_PREFIX.ts +1 -1
  66. package/src/lib/ui/compile/ABIDE_SEMANTIC_TOKENS_LEGEND.ts +40 -6
  67. package/src/lib/ui/compile/REACTIVE_CALLEES.ts +1 -1
  68. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +49 -37
  69. package/src/lib/ui/compile/abideUiPlugin.ts +1 -1
  70. package/src/lib/ui/compile/assertRuntimeHelpersBound.ts +3 -1
  71. package/src/lib/ui/compile/awaitPlan.ts +14 -2
  72. package/src/lib/ui/compile/catchBinding.ts +8 -0
  73. package/src/lib/ui/compile/classStyleMergePlan.ts +108 -0
  74. package/src/lib/ui/compile/collectAbideDiagnostics.ts +11 -0
  75. package/src/lib/ui/compile/compileComponent.ts +4 -4
  76. package/src/lib/ui/compile/compileModule.ts +20 -6
  77. package/src/lib/ui/compile/compileSSR.ts +12 -10
  78. package/src/lib/ui/compile/compileShadow.ts +107 -16
  79. package/src/lib/ui/compile/composeProps.ts +3 -3
  80. package/src/lib/ui/compile/createShadowLanguageService.ts +30 -2
  81. package/src/lib/ui/compile/createShadowProgram.ts +2 -1
  82. package/src/lib/ui/compile/createShadowScope.ts +42 -0
  83. package/src/lib/ui/compile/decodeHtmlEntities.ts +4 -0
  84. package/src/lib/ui/compile/desugarSignals.ts +12 -7
  85. package/src/lib/ui/compile/eachPlan.ts +61 -0
  86. package/src/lib/ui/compile/elementPlan.ts +55 -0
  87. package/src/lib/ui/compile/encodeSemanticTokens.ts +24 -1
  88. package/src/lib/ui/compile/generateBuild.ts +150 -127
  89. package/src/lib/ui/compile/generateSSR.ts +112 -73
  90. package/src/lib/ui/compile/ifPlan.ts +7 -1
  91. package/src/lib/ui/compile/interpolatedTemplateLiteral.ts +25 -0
  92. package/src/lib/ui/compile/isPlainIdentifier.ts +7 -0
  93. package/src/lib/ui/compile/isTextLeaf.ts +8 -7
  94. package/src/lib/ui/compile/lowerContext.ts +36 -46
  95. package/src/lib/ui/compile/lowerDocAccess.ts +2 -2
  96. package/src/lib/ui/compile/lowerScript.ts +1 -1
  97. package/src/lib/ui/compile/markupTokens.ts +313 -0
  98. package/src/lib/ui/compile/pagePropsType.ts +23 -0
  99. package/src/lib/ui/compile/parseTemplate.ts +131 -61
  100. package/src/lib/ui/compile/reactiveBinding.ts +47 -0
  101. package/src/lib/ui/compile/renameSignalRefs.ts +44 -10
  102. package/src/lib/ui/compile/skeletonable.ts +5 -5
  103. package/src/lib/ui/compile/snippetPlan.ts +28 -0
  104. package/src/lib/ui/compile/spreadExcludedNames.ts +4 -4
  105. package/src/lib/ui/compile/structuralBlockTokens.ts +3 -4
  106. package/src/lib/ui/compile/switchPlan.ts +7 -1
  107. package/src/lib/ui/compile/tryPlan.ts +8 -1
  108. package/src/lib/ui/compile/types/AnalyzedComponent.ts +2 -2
  109. package/src/lib/ui/compile/types/Binding.ts +24 -0
  110. package/src/lib/ui/compile/types/CompiledShadow.ts +13 -1
  111. package/src/lib/ui/compile/types/ElementPlan.ts +43 -0
  112. package/src/lib/ui/compile/types/ShadowKind.ts +9 -0
  113. package/src/lib/ui/compile/types/ShadowScope.ts +19 -0
  114. package/src/lib/ui/compile/types/TemplateAttr.ts +7 -0
  115. package/src/lib/ui/compile/types/TemplateNode.ts +8 -8
  116. package/src/lib/ui/compile/withBindings.ts +45 -0
  117. package/src/lib/ui/createScope.ts +2 -2
  118. package/src/lib/ui/dom/MATHML_NAMESPACE.ts +2 -2
  119. package/src/lib/ui/dom/SVG_NAMESPACE.ts +3 -2
  120. package/src/lib/ui/dom/appendText.ts +1 -1
  121. package/src/lib/ui/dom/applyResolved.ts +1 -1
  122. package/src/lib/ui/dom/buildDetachedRange.ts +4 -3
  123. package/src/lib/ui/dom/cloneStatic.ts +3 -2
  124. package/src/lib/ui/dom/each.ts +26 -7
  125. package/src/lib/ui/dom/foreignWrapperTag.ts +2 -2
  126. package/src/lib/ui/dom/mountChild.ts +8 -1
  127. package/src/lib/ui/dom/readCall.ts +6 -4
  128. package/src/lib/ui/dom/scopeLabel.ts +3 -3
  129. package/src/lib/ui/dom/switchBlock.ts +3 -1
  130. package/src/lib/ui/dom/types/EachRow.ts +4 -0
  131. package/src/lib/ui/dom/types/SkeletonHoles.ts +4 -2
  132. package/src/lib/ui/history.ts +1 -2
  133. package/src/lib/ui/html.ts +28 -0
  134. package/src/lib/ui/installHotBridge.ts +0 -2
  135. package/src/lib/ui/renderChain.ts +16 -3
  136. package/src/lib/ui/router.ts +66 -15
  137. package/src/lib/ui/rpcOutbox/createOutboxQueue.ts +1 -1
  138. package/src/lib/ui/runtime/CHILD_PRESENT.ts +8 -0
  139. package/src/lib/ui/runtime/OUTLET_MARKER.ts +2 -2
  140. package/src/lib/ui/runtime/RANGE_MARKER.ts +1 -1
  141. package/src/lib/ui/runtime/REACTIVE_CONTEXT.ts +9 -3
  142. package/src/lib/ui/runtime/captureModelDoc.ts +1 -1
  143. package/src/lib/ui/runtime/createDoc.ts +5 -1
  144. package/src/lib/ui/runtime/createEffectNode.ts +5 -5
  145. package/src/lib/ui/runtime/flushEffects.ts +22 -9
  146. package/src/lib/ui/runtime/historyEntries.ts +1 -1
  147. package/src/lib/ui/runtime/track.ts +4 -2
  148. package/src/lib/ui/runtime/trigger.ts +34 -20
  149. package/src/lib/ui/runtime/types/Route.ts +2 -2
  150. package/src/lib/ui/runtime/types/UiComponent.ts +3 -2
  151. package/src/lib/ui/runtime/types/UiProps.ts +9 -12
  152. package/src/lib/ui/runtime/walkPath.ts +15 -3
  153. package/src/lib/ui/startClient.ts +41 -14
  154. package/src/lib/ui/state.ts +3 -3
  155. package/src/lib/ui/types/Scope.ts +3 -3
  156. package/src/serverBuildPlugins.ts +11 -5
  157. package/src/zodCjsPlugin.ts +36 -0
  158. package/template/.zed/settings.json +7 -1
  159. package/template/package.json +3 -0
  160. package/template/src/ui/pages/layout.abide +3 -5
@@ -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
@@ -253,9 +274,16 @@ export function router(
253
274
  }
254
275
  const onPageHide = (): void => {
255
276
  /* Mirror the live scroll into the active entry's state before it unloads, so a
256
- reload can recover it — the in-memory bucket is gone and `manual` keeps the
257
- browser from restoring. */
277
+ non-hydratable reload (which tears the SSR DOM down) can recover it from the
278
+ manual bucket. */
258
279
  historyEntries.persist()
280
+ /* Re-enable native scroll restoration for the NEXT document load (a reload): the
281
+ browser restores the SSR DOM's offset before paint, flash-free — where the
282
+ manual bucket, gated behind the async chunk import, would re-apply post-paint
283
+ (a visible scroll). The first paint flips back to `manual` for in-session nav. */
284
+ if ('scrollRestoration' in history) {
285
+ history.scrollRestoration = 'auto'
286
+ }
259
287
  }
260
288
  const onClick = (event: MouseEvent): void => {
261
289
  /* Let the browser own anything that isn't a plain primary-button click:
@@ -297,12 +325,18 @@ export function router(
297
325
  navigatePath(destination.pathname + destination.search + destination.hash)
298
326
  }
299
327
  if (typeof window !== 'undefined') {
300
- /* Own scroll restoration: the browser would restore against the pre-teardown
301
- DOM. Adopt the initial entry's id (survives a reload) and stamp it onto the
302
- landing entry merging so any `scroll` a prior unload persisted into this
303
- entry's state stays put for the first-paint `restore` to recover. */
328
+ /* Scroll restoration is the browser's job on a document load (reload / first
329
+ paint): native restoration runs BEFORE paint, so a hydrating in-place adopt
330
+ returns to the reload offset flash-free (`auto` here also self-heals an entry a
331
+ prior session left `manual`). The first paint then flips to `manual` (below) so
332
+ an in-session back/forward — which tears the page down and rebuilds — restores
333
+ against the new DOM instead of letting the browser restore against the
334
+ torn-down one; `onPageHide` flips back to `auto` for the next load. Still adopt
335
+ the initial entry's id (survives a reload) and stamp it onto the landing entry —
336
+ merging so any `scroll` a prior unload persisted stays put for a non-hydratable
337
+ first paint's manual `restore` to recover. */
304
338
  if ('scrollRestoration' in history) {
305
- history.scrollRestoration = 'manual'
339
+ history.scrollRestoration = 'auto'
306
340
  }
307
341
  historyEntries.adopt(entryOf())
308
342
  const landingState = (history.state as AbideHistoryState | null) ?? {}
@@ -421,6 +455,7 @@ export function router(
421
455
  ) {
422
456
  divergence += 1
423
457
  }
458
+ const firstPaint = first
424
459
  const hydrating = first && pageView?.hydratable === true
425
460
  first = false
426
461
  /* Same page, same layout chain — only params/url differ (e.g. stepping
@@ -483,11 +518,27 @@ export function router(
483
518
  )
484
519
  /* Reapply the destination entry's scroll once its DOM exists — a
485
520
  back/forward restores its offset, a fresh nav scrolls to the `#hash`
486
- anchor (now built) or the top. Runs on the initial paint too: with
487
- `scrollRestoration='manual'` the browser does NOT restore a reload's
488
- offset, so first paint recovers it from the persisted `history.state`
489
- (a fresh load with no persisted offset falls through to hash/top). */
490
- historyEntries.restore(url.hash)
521
+ anchor (now built) or the top. SKIPPED on a hydrating first paint:
522
+ the SSR DOM is adopted in place, so the browser's native restoration
523
+ already returned the entry to its reload offset before paint — the
524
+ manual bucket, gated behind the async chunk import above, would only
525
+ re-apply post-paint (a visible scroll = the flash). A non-hydratable
526
+ first paint tore the SSR DOM down and rebuilt, so it still needs the
527
+ manual restore (recovered from the persisted `history.state`). */
528
+ if (!hydrating) {
529
+ historyEntries.restore(url.hash)
530
+ }
531
+ /* Take over scroll restoration once abide owns the DOM: a later
532
+ same-document back/forward must restore against the rebuilt page, so
533
+ the browser must not. `onPageHide` flips back to `auto` so the next
534
+ document load restores natively (flash-free) again. */
535
+ if (
536
+ firstPaint &&
537
+ typeof history !== 'undefined' &&
538
+ 'scrollRestoration' in history
539
+ ) {
540
+ history.scrollRestoration = 'manual'
541
+ }
491
542
  }
492
543
  /* Build / hydrate is the deterministic surface — a codegen defect or a
493
544
  throw in user render code fails the SAME way every load, so reloading
@@ -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
@@ -118,7 +118,11 @@ export function createDoc(initial: unknown): Doc {
118
118
  and this very descend scan degrades linearly with it. The woken
119
119
  reader re-mints a fresh node on its flush if the path ever returns.
120
120
  Deleting the current entry mid-iteration is safe on a Map. */
121
- const walk = walkPath(tree, candidate)
121
+ /* Walk from the already-resolved container (`rootValue`) using only the
122
+ path SUFFIX past the shared prefix, instead of re-walking every
123
+ candidate's full path from the tree root — the prefix is walked once
124
+ per wake, not once per descendant. */
125
+ const walk = walkPath(rootValue, candidate.slice(prefix.length))
122
126
  if (walk.exists) {
123
127
  writeNode(node, walk.value)
124
128
  } else {
@@ -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)
@@ -1,20 +1,33 @@
1
1
  import { REACTIVE_CONTEXT } from './REACTIVE_CONTEXT.ts'
2
+ import type { ReactiveNode } from './types/ReactiveNode.ts'
2
3
  import { updateIfNecessary } from './updateIfNecessary.ts'
3
4
 
4
5
  /*
5
6
  Drains queued effects synchronously. Each is queued when it first leaves CLEAN, but
6
7
  runs only if `updateIfNecessary` finds a dependency that truly changed — a CHECK
7
8
  effect whose deps all memoised back to equal values settles to CLEAN without
8
- running its body. Snapshots and clears the queue each pass so an effect that dirties
9
- further effects re-queues them for the next pass rather than mutating the set
10
- mid-iteration; loops until the graph settles.
9
+ running its body. Double-buffers the queue each pass swaps in a fresh array and
10
+ drains the captured one so an effect that dirties further effects re-queues them
11
+ for the next pass rather than mutating the array mid-iteration; loops until the
12
+ graph settles. The swap reuses the drained array as the next spare, so a steady
13
+ flush allocates nothing.
11
14
  */
12
15
  export function flushEffects(): void {
13
- while (REACTIVE_CONTEXT.pendingEffects.size > 0) {
14
- const batch = [...REACTIVE_CONTEXT.pendingEffects]
15
- REACTIVE_CONTEXT.pendingEffects.clear()
16
- for (const node of batch) {
17
- updateIfNecessary(node)
18
- }
16
+ /* Empty-queue fast path allocates nothing — only the length check, matching the
17
+ old `Set.size` guard. Hot: a write with no subscribed effect (e.g. a reconcile's
18
+ index-cell write nobody reads) still calls here at batch-exit, thousands of times
19
+ per list op; the spare array below must not be born on that path. */
20
+ if (REACTIVE_CONTEXT.pendingEffects.length === 0) {
21
+ return
19
22
  }
23
+ let spare: ReactiveNode[] = []
24
+ do {
25
+ const batch = REACTIVE_CONTEXT.pendingEffects
26
+ REACTIVE_CONTEXT.pendingEffects = spare
27
+ for (let index = 0; index < batch.length; index += 1) {
28
+ updateIfNecessary(batch[index] as ReactiveNode)
29
+ }
30
+ batch.length = 0
31
+ spare = batch
32
+ } while (REACTIVE_CONTEXT.pendingEffects.length > 0)
20
33
  }
@@ -2,7 +2,7 @@ import type { AbideHistoryState } from './types/AbideHistoryState.ts'
2
2
 
3
3
  /*
4
4
  Per-history-entry scroll buckets for manual scroll restoration. The browser's own
5
- restoration is disabled (`history.scrollRestoration = 'manual'` at router boot)
5
+ restoration is disabled (`history.scrollRestoration = 'manual'` after the first build/hydration)
6
6
  because the router tears the page down and rebuilds it AFTER the browser would have
7
7
  restored scroll — so the offset is lost against a node that no longer exists. Instead
8
8
  each history entry carries a monotonic id (stamped into `history.state.abideEntry` by
@@ -15,8 +15,10 @@ observer is reading the same source in the same order as before — advance the
15
15
  cursor and reuse the edge, allocating nothing. Otherwise splice a fresh edge in at
16
16
  the cursor; `runNode` trims whatever stale edges trail the cursor when compute ends.
17
17
 
18
- A consecutive re-read of the same source (`dep` already the edge at the cursor) is
19
- absorbed by the reuse check, so `{x} {x}` in one computation links `x` once.
18
+ On re-runs, an edge whose `dep` matches the next slot in the established list is
19
+ reused in-place (cursor advances, no allocation). New edges are still appended for
20
+ reads that extend the list or find a mismatched slot — including a second consecutive
21
+ read of the same source when no matching edge yet follows the cursor.
20
22
  */
21
23
  export function track(dep: ReactiveNode): void {
22
24
  const sub = REACTIVE_CONTEXT.observer
@@ -15,27 +15,41 @@ reaches). `nextSub` is read before recursing so the walk holds no edge a deeper
15
15
  pass could detach.
16
16
  */
17
17
  function mark(node: ReactiveNode, status: number): void {
18
- if (node.status >= status) {
19
- return
20
- }
21
- const wasClean = node.status === NODE_STATE.CLEAN
22
- node.status = status
23
- if (node.isEffect) {
24
- if (wasClean) {
25
- REACTIVE_CONTEXT.pendingEffects.add(node)
18
+ /* Tail-iterative down the subscriber cone: a node with a single subscriber (a
19
+ linear computed chain) advances by reassigning `node` instead of recursing, so a
20
+ deep chain costs a loop, not a 500-frame call stack. Only a true fan-out (>1
21
+ subscriber) recurses, and only for the extra branches. */
22
+ let nextStatus = status
23
+ for (;;) {
24
+ if (node.status >= nextStatus) {
25
+ return
26
26
  }
27
- return
28
- }
29
- /* A computed propagates CHECK to its subscribers only on its first move out of
30
- CLEAN they are already CHECK on any later upgrade, so re-walking is wasted. */
31
- if (!wasClean) {
32
- return
33
- }
34
- let link = node.subsHead
35
- while (link !== undefined) {
36
- const next = link.nextSub
37
- mark(link.sub, NODE_STATE.CHECK)
38
- link = next
27
+ const wasClean = node.status === NODE_STATE.CLEAN
28
+ node.status = nextStatus
29
+ if (node.isEffect) {
30
+ if (wasClean) {
31
+ REACTIVE_CONTEXT.pendingEffects.push(node)
32
+ }
33
+ return
34
+ }
35
+ /* A computed propagates CHECK to its subscribers only on its first move out of
36
+ CLEAN they are already CHECK on any later upgrade, so re-walking is wasted. */
37
+ if (!wasClean) {
38
+ return
39
+ }
40
+ const head = node.subsHead
41
+ if (head === undefined) {
42
+ return
43
+ }
44
+ /* Recurse into every branch but the last; continue the loop on the last, so the
45
+ common single-subscriber chain never recurses. */
46
+ let link = head
47
+ while (link.nextSub !== undefined) {
48
+ mark(link.sub, NODE_STATE.CHECK)
49
+ link = link.nextSub
50
+ }
51
+ node = link.sub
52
+ nextStatus = NODE_STATE.CHECK
39
53
  }
40
54
  }
41
55
 
@@ -1,8 +1,8 @@
1
1
  /* A routable page/layout. Callable to mount directly into a host (the direct-mount
2
2
  API), but the router instead uses `build` — the bare client build — to fill the
3
3
  layer into its outlet boundary as a marker range (no `<abide-outlet>` wrapper; see
4
- `fillBoundary`/`outlet`). `hydratable` (false when the page has an `await` block)
5
- tells the router whether the first paint adopts the SSR DOM in place. */
4
+ `fillBoundary`/`outlet`). `hydratable` (always true for compiled components; undefined on manually-constructed
5
+ Route objects) tells the router whether the first paint adopts the SSR DOM in place. */
6
6
  export type Route = ((host: Element, props?: unknown) => (() => void) | undefined) & {
7
7
  build: (host: Node, props?: unknown) => void
8
8
  hydratable?: boolean
@@ -10,8 +10,9 @@ page/route registries carry — abide-ui's compiled-component shape.
10
10
  export type UiComponent = ((host: Element, props?: UiProps) => () => void) & {
11
11
  /* `ctx` is the request-local block-id counter, threaded so a child's ids share the
12
12
  page's depth-first numbering; omitted at the top level (a fresh counter defaults
13
- in). Returns a Promise when the component has an inline `await` (a blocking `{#await
14
- … then}` block or a child render); otherwise renders synchronously. Callers `await`
13
+ in). Returns a Promise when the component contains an inline `await` a blocking
14
+ `{#await … then}` block, a child-component render, a `<slot>` read, or a
15
+ top-level `await` in the author script; otherwise renders synchronously. Callers `await`
15
16
  it either way (awaiting a sync value just returns it). */
16
17
  render: (props?: UiProps, ctx?: RenderContext) => SsrRender | Promise<SsrRender>
17
18
  hydrate?: (host: Element, props?: UiProps) => () => void
@@ -1,14 +1,11 @@
1
1
  /*
2
- What a component is invoked with. Two real shapes flow through the one parameter.
3
- A top-level page/layout is called by the router (client) and `renderChain` (SSR)
4
- with its decoded route params a plain string map. A nested child is called by
5
- the compiler-emitted `mountChild` with a map of reactive thunks (each authored
6
- prop, read in the body as `$props[name]?.()`) plus an optional `$children` slot
7
- builder carrying the component's `<slot>` markup, mounting into the host element
8
- it is handed.
2
+ What a component is invoked with. A top-level page/layout is called by the router
3
+ (client) and `renderChain` (SSR) with its route params as reactive thunks, shaped
4
+ identically to the thunk map `mountChild` passes a nested child so `props()` reads
5
+ (`$props[name]?.()`) work uniformly on both. `$children` is the slot builder a parent
6
+ component passes (carrying the component's `{children()}` content), or `CHILD_PRESENT`
7
+ the router/SSR set on a layout that has a child layer below it.
9
8
  */
10
- export type UiProps =
11
- | Record<string, string>
12
- | (Record<string, () => unknown> & {
13
- $children?: (host: Element) => void
14
- })
9
+ export type UiProps = Record<string, (() => unknown) | ((host: Element) => void)> & {
10
+ $children?: (host: Element) => void
11
+ }
@@ -17,13 +17,25 @@ export function walkPath(tree: unknown, path: string): PathWalk {
17
17
  if (path === '') {
18
18
  return { exists: tree !== undefined, value: tree }
19
19
  }
20
+ /* A path with no `~` carries no escape in any segment, so the per-segment
21
+ unescape is skipped wholesale — the case for every plain-identifier path. */
22
+ const escaped = path.includes('~')
20
23
  let current: unknown = tree
21
- for (const rawSegment of path.split('/')) {
22
- const segment = unescapeKey(rawSegment)
24
+ /* Scan `/`-separated segments by index, no `split` array allocated — this runs
25
+ once per candidate on the structural descend scan, the hot path's hot loop. */
26
+ let start = 0
27
+ for (;;) {
28
+ const slash = path.indexOf('/', start)
29
+ const end = slash === -1 ? path.length : slash
30
+ const raw = path.slice(start, end)
31
+ const segment = escaped ? unescapeKey(raw) : raw
23
32
  if (current === null || typeof current !== 'object' || !(segment in current)) {
24
33
  return { exists: false, value: undefined }
25
34
  }
26
35
  current = (current as Record<string, unknown>)[segment]
36
+ if (slash === -1) {
37
+ return { exists: true, value: current }
38
+ }
39
+ start = slash + 1
27
40
  }
28
- return { exists: true, value: current }
29
41
  }
@@ -1,26 +1,41 @@
1
1
  import { createCacheStore } from '../shared/createCacheStore.ts'
2
+ import { healthSeedSlot } from '../shared/healthSeedSlot.ts'
3
+ import { rpcTimeoutSlot } from '../shared/rpcTimeoutSlot.ts'
4
+ import { setAppName } from '../shared/setAppName.ts'
2
5
  import { setBaseResolver } from '../shared/setBaseResolver.ts'
3
6
  import { setCacheStoreResolver } from '../shared/setCacheStoreResolver.ts'
4
7
  import { setGlobalCacheStoreResolver } from '../shared/setGlobalCacheStoreResolver.ts'
5
8
  import { setPageResolver } from '../shared/setPageResolver.ts'
6
9
  import type { CacheSnapshotEntry } from '../shared/types/CacheSnapshotEntry.ts'
7
10
  import type { StreamedResolution } from '../shared/types/StreamedResolution.ts'
8
- import { installHotBridge } from './installHotBridge.ts'
9
- import { installInspectorBridge } from './installInspectorBridge.ts'
10
11
  import { probeNavigation } from './probeNavigation.ts'
12
+
13
+ /* Build-time flag the production client defines false (see build.ts `define`) so the dev-only
14
+ hot bridge — and the entire DOM runtime it statically pulls in for re-builds — is dead-code-
15
+ eliminated, not even emitted as a chunk. Dev defines it true; the test preload sets it on
16
+ globalThis so the bare reference resolves there. */
17
+ declare const __ABIDE_DEV__: boolean
18
+
11
19
  import { router } from './router.ts'
12
20
  import { clientPage } from './runtime/clientPage.ts'
13
21
  import type { RouteLoader } from './runtime/types/RouteLoader.ts'
14
22
  import { seedResolved } from './seedResolved.ts'
15
23
 
16
24
  /* The server's __SSR__ payload this entry consumes. */
17
- type SsrPayload = { cache?: CacheSnapshotEntry[]; base?: string }
25
+ type SsrPayload = {
26
+ cache?: CacheSnapshotEntry[]
27
+ base?: string
28
+ app?: string
29
+ health?: Record<string, unknown>
30
+ clientTimeout?: number
31
+ }
18
32
 
19
33
  /*
20
34
  The official abide-ui client entry. Reads the server's `window.__SSR__` payload,
21
35
  seeds a tab-scoped cache store from the inline snapshot (so a warm `cache()` read
22
- resolves synchronously and the matching `<template await>` adopts the SSR DOM with
23
- no re-fetch), installs the mount base, and starts the router which imports the
36
+ resolves synchronously and the matching `{#await}` adopts the SSR DOM with
37
+ no re-fetch), installs the mount base, seeds the per-page __SSR__ stamps (app
38
+ name, health, RPC timeout), and starts the router — which imports the
24
39
  current route's chunk, adopts the server-rendered `#app`, then drives SPA
25
40
  navigation — importing each further page's chunk on first visit and probing the
26
41
  destination through the server's app.handle so auth/redirect gating still applies.
@@ -37,19 +52,31 @@ export function startClient(
37
52
  if (target === null) {
38
53
  throw new Error('[abide] startClient: missing #app target')
39
54
  }
40
- /* Dev only: the live-reload script sets `__abideDev` before this module runs,
41
- so the runtime bridge is in place before any component mounts and records
42
- its instances (mountChild) for hot replacement. */
43
- if ((globalThis as { __abideDev?: boolean }).__abideDev) {
44
- installHotBridge()
55
+ /* Dev only: the live-reload script sets `__abideDev` before this module runs, so the
56
+ runtime bridge is in place before any component mounts and records its instances
57
+ (mountChild) for hot replacement. Lazy-imported and `__ABIDE_DEV__`-gated so the
58
+ bridge (and the DOM runtime it drags in) is fully dead-code-eliminated in production
59
+ rather than shipped behind a runtime flag the minifier can't prove false. */
60
+ if (__ABIDE_DEV__ && (globalThis as { __abideDev?: boolean }).__abideDev) {
61
+ import('./installHotBridge.ts').then((module) => module.installHotBridge())
45
62
  }
46
- /* Inspector only: the server injects `__abideInspect` when ABIDE_ENABLE_INSPECTOR
47
- is on, so the scope/router bridge arms before the router builds any scope. */
63
+ /* Inspector only: the server injects `__abideInspect` when ABIDE_ENABLE_INSPECTOR is on,
64
+ so the scope/router bridge arms before the router builds any scope. Inspector can be
65
+ enabled in production, so this stays a lazy chunk (not `__ABIDE_DEV__`-gated) — emitted
66
+ but fetched only when the flag is set, never weighing down the default client load. */
48
67
  if ((globalThis as { __abideInspect?: boolean }).__abideInspect) {
49
- installInspectorBridge()
68
+ import('./installInspectorBridge.ts').then((module) => module.installInspectorBridge())
50
69
  }
51
70
  const ssr = (globalThis as { __SSR__?: SsrPayload }).__SSR__ ?? {}
52
71
  setBaseResolver(() => ssr.base ?? '')
72
+ /* Seed the per-page __SSR__ stamps into their shared slots before mount: the app
73
+ name (default log channel), the health payload (so health()'s first probe is warm),
74
+ and the env-configured RPC timeout (ABIDE_CLIENT_TIMEOUT, shipped per request).
75
+ Without this the browser falls back to channel 'app', a cold first health probe,
76
+ and unbounded RPC fetches. */
77
+ setAppName(ssr.app)
78
+ healthSeedSlot.payload = ssr.health
79
+ rpcTimeoutSlot.ms = ssr.clientTimeout
53
80
  /* The `page` proxy reads route/params/url off the router-updated snapshot. */
54
81
  setPageResolver(() => clientPage.value)
55
82
 
@@ -61,7 +88,7 @@ export function startClient(
61
88
  (inline — reads settled at render-return, in __SSR__) and `__abideResumeCache` (pending
62
89
  {#await} reads whose `__abideResolve(...)` chunks the stream pushed during parse, before
63
90
  this deferred bundle ran). A warm entry lets a `cache()` read resolve synchronously so
64
- `<template await>` adopts without a refetch; a miss marker re-fetches live. */
91
+ `{#await}` adopts without a refetch; a miss marker re-fetches live. */
65
92
  const streamed =
66
93
  (globalThis as { __abideResumeCache?: StreamedResolution[] }).__abideResumeCache ?? []
67
94
  for (const resolution of [...(ssr.cache ?? []), ...streamed]) {
@@ -7,9 +7,9 @@ import { writeNode } from './runtime/writeNode.ts'
7
7
  A writable reactive cell — abide's from-scratch reactive primitive, with no
8
8
  compiler sigil and no external reactivity-library import. `.value` is a
9
9
  plain getter/setter over a signal node, so a read/write shows up as exactly that
10
- in a stack trace. The compiler's job (later) is only to auto-deref `{cell}` in
11
- templates and tag this declaration as a serializable manifest slot; the runtime
12
- needs no magic.
10
+ in a stack trace. The compiler enforces scope attachment (bare `state()` is a compile
11
+ error use `scope().state()`), desugars plain `state(initial)` to a serializable `model`
12
+ doc slot, and keeps `state(initial, transform)` as a `.value` cell; the runtime needs no magic.
13
13
 
14
14
  `transform` is an optional coercion gate on the write path: every `.value =`
15
15
  runs it and stores what it returns, with `previous` for clamp-relative writes or
@@ -17,9 +17,9 @@ methods so a scope is a passable value: `<Child parentScope={scope} />`.
17
17
  Capabilities route where the scope's changes go: `record()` to an undo journal,
18
18
  `persist()` to durable storage, `broadcast()` to peers — declared once, then
19
19
  `undo`/`redo` act on a recorded scope. `id` is the scope's identity for the
20
- boundary-crossing capabilities — `persist()` defaults its key to it. `scope()` is
21
- the ONLY public entry; everything else is a method reached through it (the
22
- `history`/`persist`/`sync` helpers it composes are internal).
20
+ boundary-crossing capabilities — `persist()` defaults its key to it. `scope()` is the only user-facing public entry; `effect` is also exported
21
+ but only for generated binding code and the SSR strip (tagged plumbing). The
22
+ `history`/`persist`/`sync` helpers it composes are internal.
23
23
  */
24
24
  export type Scope = {
25
25
  readonly id: string