@abide/abide 0.48.0 → 0.49.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 (67) hide show
  1. package/AGENTS.md +15 -7
  2. package/CHANGELOG.md +39 -0
  3. package/README.md +9 -3
  4. package/package.json +3 -1
  5. package/src/buildCli.ts +3 -5
  6. package/src/buildDisconnected.ts +2 -3
  7. package/src/bundleApp.ts +2 -3
  8. package/src/compile.ts +2 -4
  9. package/src/lib/bundle/installDownloads.ts +13 -3
  10. package/src/lib/bundle/installMacMenu.ts +13 -3
  11. package/src/lib/cli/parseArgvForRpc.ts +36 -9
  12. package/src/lib/cli/tokenizeArgvFlags.ts +4 -2
  13. package/src/lib/mcp/createMcpResourceServer.ts +13 -2
  14. package/src/lib/mcp/toolResultFromResponse.ts +40 -12
  15. package/src/lib/server/rpc/parseArgs.ts +15 -1
  16. package/src/lib/server/rpc/runWithRpcTimeout.ts +12 -1
  17. package/src/lib/server/runtime/finalizeResponse.ts +11 -1
  18. package/src/lib/server/runtime/snapshotEntryFromCache.ts +10 -2
  19. package/src/lib/shared/buildArtifact.ts +17 -0
  20. package/src/lib/shared/buildRpcRequest.ts +6 -2
  21. package/src/lib/shared/canonicalJson.ts +24 -1
  22. package/src/lib/shared/createChannelLog.ts +24 -0
  23. package/src/lib/shared/exitOnBuildFailure.ts +4 -3
  24. package/src/lib/shared/parseEnv.ts +17 -6
  25. package/src/lib/shared/serializeEnv.ts +18 -6
  26. package/src/lib/shared/snippet.ts +11 -6
  27. package/src/lib/shared/url.ts +5 -0
  28. package/src/lib/ui/compile/UI_RUNTIME_IMPORTS.ts +1 -0
  29. package/src/lib/ui/compile/bindListenEvent.ts +5 -6
  30. package/src/lib/ui/compile/compileSSR.ts +1 -1
  31. package/src/lib/ui/compile/compileShadow.ts +121 -25
  32. package/src/lib/ui/compile/composeProps.ts +3 -2
  33. package/src/lib/ui/compile/desugarSignals.ts +1 -1
  34. package/src/lib/ui/compile/generateBuild.ts +23 -9
  35. package/src/lib/ui/compile/generateSSR.ts +49 -37
  36. package/src/lib/ui/compile/isWhitespaceText.ts +10 -2
  37. package/src/lib/ui/compile/lowerDocAccess.ts +39 -1
  38. package/src/lib/ui/compile/parseTemplate.ts +3 -1
  39. package/src/lib/ui/compile/renameSignalRefs.ts +5 -18
  40. package/src/lib/ui/compile/resolveReactiveExport.ts +4 -7
  41. package/src/lib/ui/compile/scopeCss.ts +27 -4
  42. package/src/lib/ui/dom/awaitBlock.ts +67 -29
  43. package/src/lib/ui/dom/discardBoundary.ts +24 -6
  44. package/src/lib/ui/dom/each.ts +15 -8
  45. package/src/lib/ui/dom/fillBefore.ts +6 -7
  46. package/src/lib/ui/dom/mergeProps.ts +1 -1
  47. package/src/lib/ui/dom/mountSlot.ts +1 -1
  48. package/src/lib/ui/dom/mutateDocArray.ts +38 -0
  49. package/src/lib/ui/dom/restProps.ts +2 -2
  50. package/src/lib/ui/dom/spreadProps.ts +3 -4
  51. package/src/lib/ui/dom/tryBlock.ts +10 -11
  52. package/src/lib/ui/dom/withScope.ts +7 -0
  53. package/src/lib/ui/history.ts +14 -7
  54. package/src/lib/ui/installHotBridge.ts +2 -0
  55. package/src/lib/ui/props.ts +17 -0
  56. package/src/lib/ui/renderChain.ts +7 -3
  57. package/src/lib/ui/router.ts +23 -17
  58. package/src/lib/ui/runtime/CHILD_PRESENT.ts +2 -2
  59. package/src/lib/ui/runtime/applyPatchToTree.ts +16 -3
  60. package/src/lib/ui/runtime/captureModelDoc.ts +22 -7
  61. package/src/lib/ui/runtime/createDoc.ts +28 -12
  62. package/src/lib/ui/runtime/flushEffects.ts +23 -1
  63. package/src/lib/ui/runtime/scope.ts +11 -0
  64. package/src/lib/ui/runtime/toTeardown.ts +10 -3
  65. package/src/lib/ui/runtime/types/UiProps.ts +6 -5
  66. package/src/lib/ui/runtime/withoutHydration.ts +22 -0
  67. package/src/lib/ui/state.ts +9 -3
@@ -3,7 +3,7 @@ import type { UiProps } from '../runtime/types/UiProps.ts'
3
3
  /*
4
4
  Composes a child's props from ordered layers — explicit prop runs (`{ name: () =>
5
5
  value }` thunk maps), `{...spread}` layers (`spreadProps`), and a trailing slot
6
- layer (`$children`) — into one prop bag, last layer wins per key (source order, like
6
+ layer (`children`) — into one prop bag, last layer wins per key (source order, like
7
7
  JSX). A key resolves by scanning layers in reverse for the first that carries it, so
8
8
  an explicit prop after a spread overrides it and vice-versa. Emitted only when a
9
9
  component carries a spread; the plain object literal stays the path otherwise.
@@ -8,7 +8,7 @@ import { openMarker } from './openMarker.ts'
8
8
  /*
9
9
  Mounts a component's `<slot>` content as a marker-bounded range, so a slot positions among
10
10
  static siblings exactly like a control-flow block — by `before` (create) or the claim
11
- cursor (hydrate). `render` appends the parent-supplied `$children`, or the slot's own
11
+ cursor (hydrate). `render` appends the parent-supplied `children`, or the slot's own
12
12
  fallback when none was passed; it runs once (a slot never toggles), so there is no effect or
13
13
  re-render — the markers exist only to delimit the range for create insertion and hydrate
14
14
  claiming.
@@ -0,0 +1,38 @@
1
+ import type { Doc } from '../runtime/types/Doc.ts'
2
+ import { readCall } from './readCall.ts'
3
+
4
+ /*
5
+ An in-place-mutating array method lowered on a reactive-document array. The
6
+ doc-access lowering rewrites e.g. `model.items.splice(i, 1)` to
7
+ `$$mutateDocArray(model, "items", "splice", [i, 1])` rather than the bare
8
+ `$$readCall(model.read("items"), …)` it emits for non-mutating calls. The reason:
9
+ `read` returns the live tree array by reference, so a bare `.splice()`/`.sort()`/…
10
+ would mutate the document in place while never emitting a patch — no reader would
11
+ re-render and undo/persistence/multiplayer sync (all keyed off `apply`) would
12
+ never see the change (`.push` is the one already-handled exception, lowered to
13
+ `add` patches). Cloning the array, applying the mutation to the copy, and writing
14
+ it back through `replace` emits a real patch: readers wake and the change is
15
+ journalled. Element identity is preserved for untouched entries (a shallow
16
+ `slice`), so a keyed `{#for}` reconciles instead of rebuilding. The native
17
+ method's return value (spliced-out elements, the new length, …) is returned
18
+ unchanged.
19
+ */
20
+ // @documentation plumbing
21
+ export function mutateDocArray(doc: Doc, path: string, member: string, args: unknown[]): unknown {
22
+ const current = doc.read<unknown>(path)
23
+ /* Not an array — nothing patchable to clone. Fall back to the guarded in-place
24
+ call so the author still gets readCall's authored-scope error message. */
25
+ if (!Array.isArray(current)) {
26
+ return readCall(current, path, member, args)
27
+ }
28
+ const copy = current.slice()
29
+ const method = (copy as unknown as Record<string, unknown>)[member]
30
+ if (typeof method !== 'function') {
31
+ throw new TypeError(
32
+ `abide: cannot call .${member}() — "${path}".${member} is not a function (got ${typeof method})`,
33
+ )
34
+ }
35
+ const returned = (method as (...callArgs: unknown[]) => unknown).apply(copy, args)
36
+ doc.replace(path, copy)
37
+ return returned
38
+ }
@@ -6,14 +6,14 @@ object of the UNCONSUMED prop values. A child receives prop THUNKS, so `rest.key
6
6
  unwraps them (`$props[key]?.()`), tracking as a reactive dependency; a top-level
7
7
  page/layout instead receives its route params as PLAIN values, so a non-function
8
8
  value is returned as-is rather than called (which would throw `value is not a
9
- function`). The explicitly-destructured keys and the `$children` slot are excluded.
9
+ function`). The explicitly-destructured keys and the `children` slot are excluded.
10
10
  Enumerable (`ownKeys`/`for…in`/`Object.keys`), so `{...rest}` can forward the
11
11
  remaining props onto a child or a native element. Key membership is live, but a
12
12
  consumer that captures the key SET (a `{...rest}` spread) snapshots it at that point.
13
13
  */
14
14
  // @documentation plumbing
15
15
  export function restProps(props: UiProps, consumed: string[]): Record<string, unknown> {
16
- const skip = new Set([...consumed, '$children'])
16
+ const skip = new Set([...consumed, 'children'])
17
17
  const bag = props as Record<string, unknown>
18
18
  const visible = (key: string | symbol): key is string =>
19
19
  typeof key === 'string' && !skip.has(key) && key in bag
@@ -5,7 +5,7 @@ reads a spread key exactly like an authored one (`$props[key]?.()`). `source` is
5
5
  THUNK over the spread expression (not its value), re-evaluated on each key read and
6
6
  membership test, so a reactive source stays live both when its keys mutate and when
7
7
  the whole object is replaced — and the read registers as the reader's dependency. A
8
- nullish source spreads nothing. The reserved `$children` slot key is never surfaced —
8
+ nullish source spreads nothing. The reserved `children` slot key is never surfaced —
9
9
  a source happening to carry one must not masquerade as slot content.
10
10
  */
11
11
  // @documentation plumbing
@@ -13,9 +13,8 @@ export function spreadProps(
13
13
  source: () => Record<string, unknown> | null | undefined,
14
14
  ): Record<string, () => unknown> {
15
15
  /* A spread key the merged bag exposes — present on the current source and not the
16
- reserved `$children` slot key (a source carrying one must not become slot content). */
17
- const carries = (key: string | symbol): boolean =>
18
- key !== '$children' && key in (source() ?? {})
16
+ reserved `children` slot key (a source carrying one must not become slot content). */
17
+ const carries = (key: string | symbol): boolean => key !== 'children' && key in (source() ?? {})
19
18
  return new Proxy(
20
19
  {},
21
20
  {
@@ -1,7 +1,8 @@
1
- import { claimChild } from '../runtime/claimChild.ts'
1
+ import { claimExpected } from '../runtime/claimExpected.ts'
2
2
  import { OWNER } from '../runtime/OWNER.ts'
3
3
  import { RENDER } from '../runtime/RENDER.ts'
4
4
  import { scopeGroup } from '../runtime/scopeGroup.ts'
5
+ import { withoutHydration } from '../runtime/withoutHydration.ts'
5
6
  import { discardBoundary } from './discardBoundary.ts'
6
7
  import { enterNamespace } from './enterNamespace.ts'
7
8
 
@@ -59,12 +60,14 @@ export function tryBlock(
59
60
 
60
61
  const hydration = RENDER.hydration
61
62
  if (hydration !== undefined) {
62
- const open = claimChild(hydration, parent)
63
- hydration.next.set(parent, open?.nextSibling ?? null) // advance past the open marker
63
+ // Guaranteed control-flow markers — claimExpected throws a legible desync (the close
64
+ // is caught below → rebuild the catch fresh) instead of claiming null and over-clearing.
65
+ const open = claimExpected(hydration, parent, `abide:try:${id} open marker`)
66
+ hydration.next.set(parent, open.nextSibling ?? null) // advance past the open marker
64
67
  try {
65
68
  guard(() => renderTry(parent)) // claims the guarded nodes in place
66
- const close = claimChild(hydration, parent) // claim the close marker
67
- hydration.next.set(parent, close?.nextSibling ?? null)
69
+ const close = claimExpected(hydration, parent, `/abide:try:${id} close marker`)
70
+ hydration.next.set(parent, close.nextSibling ?? null)
68
71
  } catch (error) {
69
72
  /* The server markup didn't adopt — drop the whole boundary and build the
70
73
  catch fresh in its place. */
@@ -72,15 +75,11 @@ export function tryBlock(
72
75
  if (renderCatch === undefined) {
73
76
  throw error
74
77
  }
75
- const previous = RENDER.hydration
76
- RENDER.hydration = undefined
77
- try {
78
+ withoutHydration(() => {
78
79
  const fragment = document.createDocumentFragment()
79
80
  enterNamespace(parent, () => guard(() => renderCatch(fragment, error)))
80
81
  parent.insertBefore(fragment, after)
81
- } finally {
82
- RENDER.hydration = previous
83
- }
82
+ })
84
83
  }
85
84
  return
86
85
  }
@@ -22,6 +22,13 @@ export function withScope(label: string | undefined, build: () => () => void): {
22
22
  CURRENT_SCOPE.current = lexical
23
23
  try {
24
24
  lexical.own(build())
25
+ } catch (error) {
26
+ /* `build` threw before returning its stopper — e.g. a hydration desync in one of
27
+ several sibling blocks. `own` never ran, so nothing downstream will dispose this
28
+ lexical scope; tear it down here (its child scopes/effects created before the
29
+ throw) before rethrowing, rather than leaking a live, unreachable scope. */
30
+ lexical.dispose()
31
+ throw error
25
32
  } finally {
26
33
  exitRenderPass()
27
34
  CURRENT_SCOPE.current = parentScope
@@ -53,9 +53,12 @@ export function history(doc: Doc, { limit = 100 }: { limit?: number } = {}): His
53
53
  }
54
54
  } finally {
55
55
  sink = undefined
56
- }
57
- if (captured.length > 0) {
58
- push(target, captured)
56
+ /* Push captured inverses even if `apply` threw mid-loop: the already-popped
57
+ entry is gone, so the forward patches for what DID apply must land on the
58
+ opposite stack or a partial replay becomes irreversible. */
59
+ if (captured.length > 0) {
60
+ push(target, captured)
61
+ }
59
62
  }
60
63
  }
61
64
 
@@ -86,10 +89,14 @@ export function history(doc: Doc, { limit = 100 }: { limit?: number } = {}): His
86
89
  run()
87
90
  } finally {
88
91
  sink = undefined
89
- }
90
- if (entry.length > 0) {
91
- push(undoStack, entry)
92
- redoStack.length = 0
92
+ /* Commit whatever was applied even if `run` threw partway: those patches are
93
+ already live in the doc, so their inverses must reach the undo stack or the
94
+ committed changes are left with no way to undo them. The throw still
95
+ propagates after this. */
96
+ if (entry.length > 0) {
97
+ push(undoStack, entry)
98
+ redoStack.length = 0
99
+ }
93
100
  }
94
101
  },
95
102
  dispose: () => {
@@ -16,6 +16,7 @@ import { mergeProps } from './dom/mergeProps.ts'
16
16
  import { mount } from './dom/mount.ts'
17
17
  import { mountChild } from './dom/mountChild.ts'
18
18
  import { mountSlot } from './dom/mountSlot.ts'
19
+ import { mutateDocArray } from './dom/mutateDocArray.ts'
19
20
  import { on } from './dom/on.ts'
20
21
  import { outlet } from './dom/outlet.ts'
21
22
  import { readCall } from './dom/readCall.ts'
@@ -82,6 +83,7 @@ export function installHotBridge(): void {
82
83
  restProps,
83
84
  spreadAttrs,
84
85
  readCall,
86
+ mutateDocArray,
85
87
  hydrate,
86
88
  escapeKey,
87
89
  nextBlockId,
@@ -0,0 +1,17 @@
1
+ /*
2
+ The prop reader. In a `.abide` component the compiler rewrites `const { name } =
3
+ props()` to reads off the component's prop bag (`$props`), so this runtime body
4
+ never executes there — it exists for import resolution and for typing `props()`
5
+ in plain `.ts` modules. Called directly (only possible outside a compiled
6
+ component) it throws, like `request()` outside a request scope, rather than
7
+ silently returning undefined.
8
+
9
+ The return type is `T` (default `Record<string, unknown>`); inside a `.abide`
10
+ file the check shadow supplies the file-contextual `RouteShape & T` instead.
11
+ */
12
+ // @documentation reactive-state
13
+ export function props<T = Record<string, unknown>>(): T {
14
+ throw new Error(
15
+ '[abide] props() is compiler-lowered inside a .abide component and has no runtime meaning when called directly',
16
+ )
17
+ }
@@ -35,8 +35,10 @@ export async function renderChain(
35
35
  const renders: SsrRender[] = []
36
36
  /* Route params as thunks (static server-side — only shape parity with the client so
37
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. */
38
+ gets `children` set to `CHILD_PRESENT` so SSR renders `{#if children}` the same way
39
+ the client does, keeping hydration congruent. The sentinel itself must be thunk-wrapped
40
+ (`() => CHILD_PRESENT`) since the destructure lowering CALLS every bag entry — leaving
41
+ it raw would invoke `CHILD_PRESENT` (a function) and read back `undefined`. */
40
42
  const paramThunks: UiProps = {}
41
43
  for (const key of Object.keys(params)) {
42
44
  paramThunks[key] = () => params[key]
@@ -44,7 +46,9 @@ export async function renderChain(
44
46
  for (let index = 0; index < views.length; index += 1) {
45
47
  const view = views[index] as UiComponent
46
48
  const hasChild = index < views.length - 1
47
- const props: UiProps = hasChild ? { ...paramThunks, $children: CHILD_PRESENT } : paramThunks
49
+ const props: UiProps = hasChild
50
+ ? { ...paramThunks, children: () => CHILD_PRESENT }
51
+ : paramThunks
48
52
  renders.push(await view.render(props, ctx))
49
53
  }
50
54
  let html = renders[renders.length - 1]?.html ?? ''
@@ -200,17 +200,19 @@ export function router(
200
200
  /* Route params as reactive thunks: reading `clientPage.value.params` inside the
201
201
  thunk tracks the page signal, so an in-place same-route hop (params change, the
202
202
  page stays mounted) re-runs each `props()` derive. The key set is stable across
203
- same-route hops, so the bag need not rebuild. A layout also gets `$children`
203
+ same-route hops, so the bag need not rebuild. A layout also gets `children`
204
204
  set to `CHILD_PRESENT` when a child layer exists below it, read by
205
205
  `{#if children}` (a layout's `{children()}` lowers to its `outlet()` boundary,
206
- so it ignores this value). */
206
+ so it ignores this value). Every bag entry is a thunk — `props()` destructure
207
+ lowers to `$props["key"]?.()` — so the sentinel must be wrapped too, or calling
208
+ it would invoke `CHILD_PRESENT` and yield `undefined` instead of the sentinel. */
207
209
  const propsBag = (hasChild: boolean): UiProps => {
208
210
  const bag: UiProps = {}
209
211
  for (const key of Object.keys(params)) {
210
212
  bag[key] = () => clientPage.value.params[key]
211
213
  }
212
214
  if (hasChild) {
213
- bag.$children = CHILD_PRESENT
215
+ bag.children = () => CHILD_PRESENT
214
216
  }
215
217
  return bag
216
218
  }
@@ -379,6 +381,15 @@ export function router(
379
381
  would re-run the router and re-mount the page, dropping local state. */
380
382
  const path = runtimePath.value
381
383
  untrack(() => {
384
+ /* Capture whether THIS run is the first paint, then flip the shared flag
385
+ synchronously. Previously `first` was only cleared inside the async `.then`,
386
+ so a second navigation that began before the boot resolve settled (a
387
+ double-tapped back button, two quick `navigate()` calls) still read
388
+ `first === true` and skipped its auth/redirect probe. Each run's `.then`
389
+ closes over its own `isFirstRun`, so hydration still keys off the real first
390
+ paint. */
391
+ const isFirstRun = first
392
+ first = false
382
393
  /* The route matches on the pathname only; the query/hash ride along for
383
394
  the probe (so server gating sees them) and for clientPage.url. */
384
395
  const pathname = path.split(/[?#]/)[0] ?? path
@@ -390,7 +401,7 @@ export function router(
390
401
  const targetUrl = resolveUrl(path)
391
402
  const mountedUrl = clientPage.value.url
392
403
  if (
393
- !first &&
404
+ !isFirstRun &&
394
405
  mountedUrl.pathname === targetUrl.pathname &&
395
406
  mountedUrl.search === targetUrl.search &&
396
407
  mountedUrl.hash !== targetUrl.hash
@@ -422,13 +433,13 @@ export function router(
422
433
  Skipped on first paint — there is no page to leave. An instant SPA hop
423
434
  (chunk cached, no probe) flips it back within the same microtask, so the
424
435
  browser never paints the intermediate state — no flash. */
425
- if (!first && !clientPage.value.navigating) {
436
+ if (!isFirstRun && !clientPage.value.navigating) {
426
437
  clientPage.value = { ...clientPage.value, navigating: true }
427
438
  }
428
439
  /* First paint adopts a document the server already ran handle() on;
429
440
  only later navigations re-run it through the probe. */
430
441
  const verdict: Promise<NavVerdict> =
431
- first || probe === undefined ? Promise.resolve({ kind: 'mount' }) : probe(path)
442
+ isFirstRun || probe === undefined ? Promise.resolve({ kind: 'mount' }) : probe(path)
432
443
  /* Resolve the page chunk, its layout chunks, and the gate in parallel,
433
444
  keeping the current chain mounted until all land — no blank frame while
434
445
  imports are in flight or the probe is in the air. */
@@ -439,14 +450,10 @@ export function router(
439
450
  ])
440
451
  .then(([pageView, resolvedLayouts, decision]) => {
441
452
  if (token !== sequence || disposed) {
442
- /* Consume the first-paint flag even when superseded: the boot SSR DOM
443
- is valid only for THIS boot navigation. Leaving `first` true would
444
- let a navigation that started before this settled (to a different
445
- page) skip its auth probe and hydrate the boot URL's SSR DOM as its
446
- own — claimExpected then throws and commit swallows it, stranding a
447
- broken page. Cleared here → the superseding nav builds create-mode
448
- (clearing the stale SSR DOM) and runs its probe. */
449
- first = false
453
+ /* Superseded (or disposed) drop this build. The first-paint flag was
454
+ already consumed synchronously at run start (`first = false`), so a
455
+ navigation that started after this one already saw `first === false`,
456
+ ran its probe, and builds in create mode over the stale SSR DOM. */
450
457
  return
451
458
  }
452
459
  /* handle() redirected: go where it pointed, replacing the blocked
@@ -474,9 +481,8 @@ export function router(
474
481
  ) {
475
482
  divergence += 1
476
483
  }
477
- const firstPaint = first
478
- const hydrating = first && pageView?.hydratable === true
479
- first = false
484
+ const firstPaint = isFirstRun
485
+ const hydrating = isFirstRun && pageView?.hydratable === true
480
486
  /* Same page, same layout chain — only params/url differ (e.g. stepping
481
487
  between episodes on one detail page). The whole structure survives, so
482
488
  publish the new snapshot on the reactive `page` proxy and let the mounted
@@ -1,7 +1,7 @@
1
1
  /*
2
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
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
5
  `outlet()` boundary the router fills, so this value is never invoked — it exists purely so
6
6
  `{#if children}` reads a uniform presence signal on both the client and the server.
7
7
  */
@@ -1,3 +1,4 @@
1
+ import { setOwnProperty } from '../../shared/setOwnProperty.ts'
1
2
  import type { Patch } from './types/Patch.ts'
2
3
 
3
4
  /*
@@ -21,16 +22,28 @@ export function applyPatchToTree(tree: unknown, patch: Patch, segments: string[]
21
22
  }
22
23
  let parent = tree as Record<string, unknown>
23
24
  for (const segment of segments.slice(0, -1)) {
24
- parent = parent[segment] as Record<string, unknown>
25
+ /* Only descend into an OWN property. A segment of `__proto__`/`constructor`/
26
+ `prototype` is not own on a plain data object/array, so this refuses to walk
27
+ into a shared prototype (or the constructor function) that a later write would
28
+ then pollute — the real vector, since `apply` is reachable from `sync()` with
29
+ unvalidated peer-controlled patch paths. A legit document key that happens to be
30
+ named `constructor` is an own data property, so `hasOwn` still traverses it. */
31
+ const next = Object.hasOwn(parent, segment) ? parent[segment] : undefined
32
+ if (next === null || typeof next !== 'object') {
33
+ throw new TypeError(
34
+ `abide: patch path segment "${segment}" does not address a container`,
35
+ )
36
+ }
37
+ parent = next as Record<string, unknown>
25
38
  }
26
39
  const key = segments[segments.length - 1] as string
27
40
  if (patch.op === 'replace') {
28
- parent[key] = patch.value
41
+ setOwnProperty(parent, key, patch.value)
29
42
  } else if (patch.op === 'add') {
30
43
  if (Array.isArray(parent)) {
31
44
  parent.splice(key === '-' ? parent.length : Number(key), 0, patch.value)
32
45
  } else {
33
- parent[key] = patch.value
46
+ setOwnProperty(parent, key, patch.value)
34
47
  }
35
48
  } else if (Array.isArray(parent)) {
36
49
  parent.splice(Number(key), 1)
@@ -1,28 +1,43 @@
1
1
  import { PATCH_BUS } from './PATCH_BUS.ts'
2
2
  import type { Doc } from './types/Doc.ts'
3
3
 
4
+ /* Stack of in-flight captures, innermost last. `mountChild` wraps every hot-tracked child
5
+ mount in its own `captureModelDoc`, and these NEST inside a parent's build, so a single
6
+ shared PATCH_BUS would let a parent's listener (subscribed first) grab a nested child's
7
+ model. Assigning each patch only to the innermost active capture keeps a component's model
8
+ scoped to its OWN build. */
9
+ const captureStack: { model: Doc | undefined }[] = []
10
+
4
11
  /*
5
12
  Runs a component `build` and returns its result (the mount handle / disposer)
6
13
  alongside the component's own `model` document — the serializable `state` doc,
7
14
  needed so a hot swap can carry its value across (see `hotReplace`). The model is
8
15
  found, not threaded: a component seeds its `model` first (the desugared
9
16
  `const $$model = scope()` + its init `replace` patches (state slots lowered by `lowerDocAccess`) run before any child mounts or
10
- control-flow blocks), so the FIRST patch announced on the bus during the build names
11
- it. A component with no `state` mints no model and emits nothing first — `model` is
12
- then `undefined` and there is nothing to preserve. Used only on the hot path; the
13
- subscription is torn down with the build.
17
+ control-flow blocks), so the FIRST patch announced during THIS build (not a nested
18
+ child's) names it. A component with no `state` mints no model and emits nothing first
19
+ — `model` is then `undefined` and there is nothing to preserve. A nested child mount
20
+ pushes its own frame, so its state-init patch names the child's model, not the parent's
21
+ (the M5 stateless-parent/stateful-child fix). Used only on the hot path; the subscription
22
+ is torn down with the build.
14
23
  */
15
24
  export function captureModelDoc<T>(build: () => T): {
16
25
  value: T
17
26
  model: Doc | undefined
18
27
  } {
19
- let model: Doc | undefined
28
+ const frame: { model: Doc | undefined } = { model: undefined }
29
+ captureStack.push(frame)
20
30
  const unsubscribe = PATCH_BUS.subscribe((event) => {
21
- model ??= event.doc
31
+ /* Only the innermost active capture claims a patch — a nested child mounting during
32
+ this build has pushed a deeper frame, so its patches don't leak up to this one. */
33
+ if (captureStack[captureStack.length - 1] === frame) {
34
+ frame.model ??= event.doc
35
+ }
22
36
  })
23
37
  try {
24
- return { value: build(), model }
38
+ return { value: build(), model: frame.model }
25
39
  } finally {
26
40
  unsubscribe()
41
+ captureStack.pop()
27
42
  }
28
43
  }
@@ -316,23 +316,39 @@ export function createDoc(initial: unknown): Doc {
316
316
  const node = nodeFor(path)
317
317
  const segments = pathSegments(path)
318
318
  const leafKey = segments[segments.length - 1] as string
319
- /* Auto-vivify missing ancestor objects so binding a nested path on a doc
320
- booted shallow (e.g. `state({})`) doesn't crash, and a later `set` writes
321
- into the LIVE tree (so snapshot/persist see it). Mirrors the container
322
- assumption applyPatchToTree makes except the patch path is authored, this
323
- walk is compiler-emitted, so the intermediates may not exist yet. */
324
- let parent = tree as Record<string, unknown>
325
- for (const segment of segments.slice(0, -1)) {
326
- let next = parent[segment]
327
- if (next === null || typeof next !== 'object') {
328
- next = {}
329
- parent[segment] = next
319
+ const ancestors = segments.slice(0, -1)
320
+ /* The resolved parent container, cached after the first write. `get` never needs
321
+ it (it reads through `node`), so it is not computed at cell creation. */
322
+ let parent: Record<string, unknown> | undefined
323
+ /* Auto-vivify missing ancestor objects so a `set` on a nested path bound over a
324
+ doc booted shallow (e.g. `state({})`) writes into the LIVE tree (snapshot/persist
325
+ see it) rather than crashing. Done LAZILY on the first write, NOT at cell
326
+ creation: `hoistCells` lifts a cell to component-mount scope, so vivifying eagerly
327
+ would fabricate container structure for a path only ever written behind a branch /
328
+ in a handler that never runs. Mirrors applyPatchToTree's container assumption,
329
+ but the intermediates may not exist yet since this walk is compiler-emitted. */
330
+ const resolveParent = (): Record<string, unknown> => {
331
+ let current = tree as Record<string, unknown>
332
+ for (let index = 0; index < ancestors.length; index += 1) {
333
+ const segment = ancestors[index] as string
334
+ let next = current[segment]
335
+ if (next === null || typeof next !== 'object') {
336
+ /* Create an array when the NEXT segment addresses an array element (a
337
+ numeric index or the `-` push slot), else a plain object — otherwise a
338
+ bound path like `items/0/name` on a shallow doc fabricates `items` as an
339
+ object keyed by "0", which a later `add("items/-", …)` can't push into. */
340
+ const childKey = (ancestors[index + 1] ?? leafKey) as string
341
+ next = childKey === '-' || /^\d+$/.test(childKey) ? [] : {}
342
+ current[segment] = next
343
+ }
344
+ current = next as Record<string, unknown>
330
345
  }
331
- parent = next as Record<string, unknown>
346
+ return current
332
347
  }
333
348
  return {
334
349
  get: () => readNode(node) as T,
335
350
  set: (value: T) => {
351
+ parent ??= resolveParent()
336
352
  parent[leafKey] = value
337
353
  writeNode(node, value)
338
354
  },
@@ -1,3 +1,4 @@
1
+ import { NODE_STATE } from './NODE_STATE.ts'
1
2
  import { REACTIVE_CONTEXT } from './REACTIVE_CONTEXT.ts'
2
3
  import type { ReactiveNode } from './types/ReactiveNode.ts'
3
4
  import { updateIfNecessary } from './updateIfNecessary.ts'
@@ -39,13 +40,34 @@ export function flushEffects(): void {
39
40
 
40
41
  function drain(): void {
41
42
  let spare: ReactiveNode[] = []
43
+ let errors: unknown[] | undefined
42
44
  do {
43
45
  const batch = REACTIVE_CONTEXT.pendingEffects
44
46
  REACTIVE_CONTEXT.pendingEffects = spare
45
47
  for (let index = 0; index < batch.length; index += 1) {
46
- updateIfNecessary(batch[index] as ReactiveNode)
48
+ const node = batch[index] as ReactiveNode
49
+ try {
50
+ updateIfNecessary(node)
51
+ } catch (error) {
52
+ /* One effect throwing must not strand the effects queued behind it — they
53
+ live in this same `batch`, which becomes unreachable the moment we swap
54
+ `pendingEffects`. Reset the culprit to CLEAN so a later write to its
55
+ dependencies can re-queue it (otherwise `mark`'s CLEAN→dirty gate leaves
56
+ it permanently inert), then keep draining and surface the error(s) once
57
+ the graph has settled rather than swallowing them. */
58
+ node.status = NODE_STATE.CLEAN
59
+ if (errors === undefined) {
60
+ errors = []
61
+ }
62
+ errors.push(error)
63
+ }
47
64
  }
48
65
  batch.length = 0
49
66
  spare = batch
50
67
  } while (REACTIVE_CONTEXT.pendingEffects.length > 0)
68
+ if (errors !== undefined) {
69
+ throw errors.length === 1
70
+ ? errors[0]
71
+ : new AggregateError(errors, 'abide: effects threw during flush')
72
+ }
51
73
  }
@@ -23,6 +23,17 @@ export function scope(build: () => void): () => void {
23
23
  OWNER.current = disposers
24
24
  try {
25
25
  untrack(build)
26
+ } catch (error) {
27
+ /* A build that throws partway (e.g. a hydration desync deep in a subtree) has
28
+ already created and subscribed some effects/listeners. The caller never
29
+ receives this scope's disposer on the throw path, so tear down what was built —
30
+ in reverse, children before parents — before rethrowing; otherwise those effects
31
+ stay subscribed forever with no owner able to dispose them. */
32
+ for (let index = disposers.length - 1; index >= 0; index -= 1) {
33
+ disposers[index]?.()
34
+ }
35
+ disposers.length = 0
36
+ throw error
26
37
  } finally {
27
38
  OWNER.current = previous
28
39
  }
@@ -1,3 +1,4 @@
1
+ import { abideLog } from '../../shared/abideLog.ts'
1
2
  import type { EffectResult } from './types/EffectResult.ts'
2
3
  import type { Teardown } from './types/Teardown.ts'
3
4
 
@@ -15,15 +16,21 @@ export function toTeardown(result: EffectResult): Teardown | undefined {
15
16
  }
16
17
  if (result instanceof Promise) {
17
18
  return () => {
18
- /* Swallow a rejection: an async body that rejected (e.g. an aborted RPC) must
19
- not surface as an unhandled rejection when the teardown runs at dispose. */
20
19
  result.then(
21
20
  (teardown) => {
22
21
  if (typeof teardown === 'function') {
23
22
  teardown()
24
23
  }
25
24
  },
26
- () => undefined,
25
+ (error) => {
26
+ /* A superseded reactive read aborts its in-flight RPC — expected, stays
27
+ quiet. Any OTHER rejection is a real bug in the async effect body;
28
+ surface it (the visibility goal) instead of silently discarding it. */
29
+ const aborted = (error as { name?: string } | undefined)?.name === 'AbortError'
30
+ if (!aborted) {
31
+ abideLog.error(error)
32
+ }
33
+ },
27
34
  )
28
35
  }
29
36
  }
@@ -2,10 +2,11 @@
2
2
  What a component is invoked with. A top-level page/layout is called by the router
3
3
  (client) and `renderChain` (SSR) with its route params as reactive thunks, shaped
4
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.
5
+ (`$props[name]?.()`) work uniformly on both. `children` is an ordinary prop thunk like
6
+ any other: on a component it returns the parent's `Snippet`; on a layout with a child
7
+ layer below it, the router/SSR set it to `() => CHILD_PRESENT` (a presence sentinel, not
8
+ a slot builder) so `{#if children}` reads truthy.
8
9
  */
9
- export type UiProps = Record<string, (() => unknown) | ((host: Element) => void)> & {
10
- $children?: (host: Element) => void
10
+ export type UiProps = Record<string, () => unknown> & {
11
+ children?: () => unknown
11
12
  }