@abide/abide 0.44.0 → 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 (67) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/package.json +1 -1
  3. package/src/abideResolverPlugin.ts +6 -1
  4. package/src/devEntry.ts +43 -9
  5. package/src/lib/cli/parseArgvForRpc.ts +21 -24
  6. package/src/lib/cli/runCli.ts +9 -24
  7. package/src/lib/cli/tokenizeArgvFlags.ts +84 -0
  8. package/src/lib/server/rpc/defineRpc.ts +9 -1
  9. package/src/lib/server/runtime/createServer.ts +29 -12
  10. package/src/lib/server/runtime/devHotModuleResponse.ts +1 -1
  11. package/src/lib/server/runtime/gzipResponse.ts +14 -16
  12. package/src/lib/server/runtime/runWithRequestScope.ts +4 -1
  13. package/src/lib/server/runtime/types/RequestStore.ts +16 -0
  14. package/src/lib/server/runtime/withResponseDefaults.ts +8 -3
  15. package/src/lib/server/sockets/defineSocket.ts +5 -1
  16. package/src/lib/shared/RPC_ARGS_TYPE.ts +7 -0
  17. package/src/lib/shared/activeCacheStore.ts +5 -14
  18. package/src/lib/shared/activePage.ts +5 -19
  19. package/src/lib/shared/augmentModule.ts +18 -0
  20. package/src/lib/shared/basePath.ts +6 -5
  21. package/src/lib/shared/baseResolver.ts +10 -0
  22. package/src/lib/shared/baseSlot.ts +6 -12
  23. package/src/lib/shared/cache.ts +21 -1
  24. package/src/lib/shared/cacheStoreResolver.ts +12 -0
  25. package/src/lib/shared/cacheStoreSlot.ts +5 -13
  26. package/src/lib/shared/changeAffectsClient.ts +35 -0
  27. package/src/lib/shared/createResolverSlot.ts +37 -0
  28. package/src/lib/shared/debugGate.ts +90 -0
  29. package/src/lib/shared/emitLogRecord.ts +18 -4
  30. package/src/lib/shared/globalCacheStoreResolver.ts +12 -0
  31. package/src/lib/shared/globalCacheStoreSlot.ts +6 -11
  32. package/src/lib/shared/isDebugEnabled.ts +3 -10
  33. package/src/lib/shared/isDebugNegated.ts +4 -6
  34. package/src/lib/shared/pageResolver.ts +16 -0
  35. package/src/lib/shared/pageSlot.ts +5 -14
  36. package/src/lib/shared/requestScopeResolver.ts +12 -0
  37. package/src/lib/shared/requestScopeSlot.ts +7 -12
  38. package/src/lib/shared/responseBodyKind.ts +35 -0
  39. package/src/lib/shared/setBaseResolver.ts +2 -4
  40. package/src/lib/shared/setCacheStoreResolver.ts +3 -5
  41. package/src/lib/shared/setGlobalCacheStoreResolver.ts +3 -5
  42. package/src/lib/shared/setPageResolver.ts +2 -5
  43. package/src/lib/shared/setRequestScopeResolver.ts +3 -5
  44. package/src/lib/shared/types/DebugGate.ts +10 -0
  45. package/src/lib/shared/types/ResolverSlot.ts +10 -0
  46. package/src/lib/shared/writeDts.ts +8 -1
  47. package/src/lib/shared/writePublicAssetsDts.ts +5 -9
  48. package/src/lib/shared/writeRoutesDts.ts +12 -16
  49. package/src/lib/shared/writeRpcDts.ts +13 -12
  50. package/src/lib/shared/writeTestRpcDts.ts +11 -11
  51. package/src/lib/shared/writeTestSocketsDts.ts +9 -9
  52. package/src/lib/ui/compile/abideUiPlugin.ts +9 -7
  53. package/src/lib/ui/compile/assertRuntimeHelpersBound.ts +24 -12
  54. package/src/lib/ui/compile/compileModule.ts +37 -16
  55. package/src/lib/ui/compile/compileShadow.ts +75 -1
  56. package/src/lib/ui/compile/createShadowLanguageService.ts +29 -3
  57. package/src/lib/ui/compile/generateBuild.ts +7 -1
  58. package/src/lib/ui/compile/parseTemplate.ts +4 -1
  59. package/src/lib/ui/compile/types/TemplateNode.ts +1 -1
  60. package/src/lib/ui/dom/mountSwappableRange.ts +79 -0
  61. package/src/lib/ui/dom/skeleton.ts +10 -1
  62. package/src/lib/ui/dom/switchBlock.ts +15 -63
  63. package/src/lib/ui/dom/when.ts +11 -64
  64. package/src/lib/ui/runtime/createDoc.ts +102 -12
  65. package/src/zodCjsPlugin.ts +16 -1
  66. package/src/lib/shared/matchesDebugPattern.ts +0 -16
  67. package/src/lib/shared/parseDebugPatterns.ts +0 -21
@@ -0,0 +1,79 @@
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'
9
+
10
+ /*
11
+ The shared lifecycle of every single-slot control-flow block (`when`, `switchBlock`):
12
+ a marker-bounded RANGE that holds at most one piece of content at a time, swapped as a
13
+ unit when a `key` changes. The block supplies a `key()` thunk (the identity of the
14
+ chosen branch — `'then'|'else'` for `when`, the case index for `switchBlock`) and a
15
+ `contentFor(key)` resolver returning that branch's builder, or `undefined` for an
16
+ empty branch. This module owns the marker setup, the hydrate-vs-create fork, and the
17
+ swap effect with its reentrancy/teardown dance — the structure both blocks copy-pasted.
18
+
19
+ The chosen branch builds through `scope` (directly on hydrate, via `fillBefore` on a
20
+ swap), which builds untracked — so a raw reactive read in the branch content doesn't
21
+ subscribe the swap effect; only `key()` (which reads the block's source) drives the
22
+ toggle. The branch's own interpolations still track, each through its own effect.
23
+
24
+ On hydrate it adopts the server-rendered range: claim the start marker, run the chosen
25
+ branch in place (its content claims the existing nodes), then claim the end marker. The
26
+ effect's first run sees the same key and is a no-op; later changes clear the range and
27
+ build fresh into a fragment.
28
+
29
+ `before` (a static node located by the skeleton) places the range among siblings on
30
+ create, so the block sits before a static suffix rather than at the parent's end.
31
+ Hydrate ignores it — the claim cursor (positioned past the prefix) drives placement.
32
+ */
33
+ export function mountSwappableRange<Key>(
34
+ parent: Node,
35
+ key: () => Key,
36
+ contentFor: (key: Key) => ((parent: Node) => void) | undefined,
37
+ before: Node | null = null,
38
+ ): void {
39
+ const hydration = RENDER.hydration
40
+ /* The live branch's scope, registered with the owner so it disposes on owner
41
+ teardown — not only on a branch swap via replaceRange. */
42
+ const group = scopeGroup()
43
+ let dispose: (() => void) | undefined
44
+ let activeKey: Key
45
+ let end: Comment
46
+
47
+ const start = openMarker(parent, RANGE_OPEN, before)
48
+ if (hydration !== undefined) {
49
+ activeKey = key()
50
+ const chosen = contentFor(activeKey)
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
+ activeKey = key()
58
+ const chosen = contentFor(activeKey)
59
+ if (chosen !== undefined) {
60
+ dispose = group.track(fillBefore(end, chosen))
61
+ }
62
+ }
63
+
64
+ effect(() => {
65
+ const next = key()
66
+ if (next === activeKey) {
67
+ return
68
+ }
69
+ activeKey = next
70
+ const chosen = contentFor(next)
71
+ /* Null `dispose` before `replaceRange` builds the new branch: a reentrant swap
72
+ during that build (an effect in the new content writing the source) would
73
+ otherwise re-enter with the already-disposed disposer and clear it twice. */
74
+ const prior = dispose
75
+ dispose = undefined
76
+ const built = replaceRange(start, end, prior, chosen)
77
+ dispose = built !== undefined ? group.track(built) : undefined
78
+ })
79
+ }
@@ -129,10 +129,19 @@ export function skeleton(parent: Node, html: string): SkeletonHoles {
129
129
  hydration.next.set(parent, node)
130
130
  } else {
131
131
  const children = source.childNodes
132
+ /* Stage clones in a fragment ONLY for a live (connected) parent, where one
133
+ append reflows once instead of per clone. A detached parent triggers no
134
+ reflow on append, so the fragment is pure overhead there — skip it and
135
+ append direct. `topLevel` collects each clone either way for anchor/
136
+ element-hole resolution. */
137
+ const target: Node = parent.isConnected ? document.createDocumentFragment() : parent
132
138
  for (let index = 0; index < children.length; index += 1) {
133
139
  const clone = (children[index] as Node).cloneNode(true)
134
140
  topLevel.push(clone)
135
- parent.appendChild(clone)
141
+ target.appendChild(clone)
142
+ }
143
+ if (target !== parent) {
144
+ parent.appendChild(target)
136
145
  }
137
146
  }
138
147
  /* Anchor holes via the ONE shared ordering rule (`walkAnchorOrder`) — the same traversal
@@ -1,26 +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
5
  Multi-branch binding — the runtime for `{#switch}` blocks and for `{#if}` chains that
13
6
  include `{:elseif}` branches (compiled as a switch over `true` with Boolean-coerced
14
- match thunks so the first truthy branch wins). An effect evaluates the
7
+ match thunks so the first truthy branch wins). A swappable range evaluates the
15
8
  subject, picks the first case whose `match` equals it (strict `===`), falling back
16
9
  to the default (`match` undefined); the chosen case's content lives in a RANGE
17
10
  bounded by two comment markers, so a case holds any content. Staying on the same
18
11
  case across a subject change leaves it mounted; switching clears the range and
19
- builds the new case fresh.
20
-
21
- On hydrate it adopts the case the server rendered: claim the start marker, run the
22
- matching case in place, claim the end marker. The effect's first run picks the same
23
- 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.
24
14
  */
25
15
  // @documentation plumbing
26
16
  export function switchBlock(
@@ -29,59 +19,21 @@ export function switchBlock(
29
19
  cases: SwitchCase[],
30
20
  before: Node | null = null,
31
21
  ): void {
32
- const hydration = RENDER.hydration
33
- /* The live case's scope, registered with the owner so it disposes on owner
34
- teardown — not only when the subject switches cases via replaceRange. */
35
- const group = scopeGroup()
36
- let dispose: (() => void) | undefined
37
- let activeIndex: number
38
- let end: Comment
39
-
22
+ /* Pick the first case matching the subject (`===`), else the default (`match`
23
+ undefined), else -1 for no match. */
40
24
  const select = (value: unknown): number => {
41
25
  const matched = cases.findIndex(
42
26
  (entry) => entry.match !== undefined && entry.match() === value,
43
27
  )
44
28
  return matched === -1 ? cases.findIndex((entry) => entry.match === undefined) : matched
45
29
  }
46
- const caseAt = (index: number): SwitchCase | undefined =>
47
- index === -1 ? undefined : cases[index]
48
- /* The chosen case builds through `scope` (directly on hydrate, via `fillBefore` on a
49
- swap), which builds untracked — so a raw reactive read in the case content doesn't
50
- subscribe the swap effect below; only `subject()` (and each case's `match()`) drives
51
- the swap. The case's own interpolations still track, each through its own effect. */
52
-
53
- /* `before` places the range among static siblings on create (block before a suffix);
54
- hydrate ignores it and uses the parked claim cursor. */
55
- const start = openMarker(parent, RANGE_OPEN, before)
56
- if (hydration !== undefined) {
57
- activeIndex = select(subject())
58
- const chosen = caseAt(activeIndex)
59
- if (chosen !== undefined) {
60
- dispose = group.track(scope(() => chosen.render(parent))) // claim the SSR nodes in place
61
- }
62
- end = openMarker(parent, RANGE_CLOSE)
63
- } else {
64
- end = openMarker(parent, RANGE_CLOSE, before)
65
- activeIndex = select(subject())
66
- const chosen = caseAt(activeIndex)
67
- if (chosen !== undefined) {
68
- dispose = group.track(fillBefore(end, (p) => chosen.render(p)))
69
- }
70
- }
71
-
72
- effect(() => {
73
- const index = select(subject())
74
- if (index === activeIndex) {
75
- return
76
- }
77
- activeIndex = index
78
- const chosen = caseAt(index)
79
- /* Null `dispose` before `replaceRange` builds the new case: a reentrant switch
80
- during that build (an effect in the new content writing the subject) would
81
- otherwise re-enter with the already-disposed disposer and clear it twice. */
82
- const prior = dispose
83
- dispose = undefined
84
- const next = replaceRange(start, end, prior, chosen && ((p) => chosen.render(p)))
85
- dispose = next !== undefined ? group.track(next) : undefined
86
- })
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
+ )
87
39
  }
@@ -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
  }
@@ -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,25 +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. */
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]) {
121
190
  /* Walk from the already-resolved container (`rootValue`) using only the
122
191
  path SUFFIX past the shared prefix, instead of re-walking every
123
192
  candidate's full path from the tree root — the prefix is walked once
124
193
  per wake, not once per descendant. */
125
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)
126
201
  if (walk.exists) {
127
- 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
+ }
128
209
  } else {
129
- writeNode(node, undefined)
130
- 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)
131
221
  }
132
222
  }
133
223
  }
@@ -20,6 +20,14 @@ the bug while keeping zod v4. Server-only: the client bundle strips zod from rpc
20
20
  modules (they become remote proxies), and dev never bundles.
21
21
  */
22
22
  export function zodCjsPlugin(cwd: string): BunPlugin {
23
+ /*
24
+ The (specifier, importer-dir) → cjs path mapping is deterministic per build —
25
+ zod's location doesn't move mid-build, and the filter fires for every zod
26
+ import in every module. Memoise on a `${path}\0${from}` key (mirrors the
27
+ resolver's resolveExtension cache) so the fs round-trips (resolveSync +
28
+ existsSync) run once per unique pair instead of once per import.
29
+ */
30
+ const cjsPathCache = new Map<string, string>()
23
31
  return {
24
32
  name: 'abide-zod-cjs',
25
33
  setup(build) {
@@ -27,9 +35,16 @@ export function zodCjsPlugin(cwd: string): BunPlugin {
27
35
  /* Resolve from the importer (or cwd for the entry) to zod's ESM
28
36
  target, then swap to its `.cjs` sibling when one exists. */
29
37
  const from = args.importer ? dirname(args.importer) : cwd
38
+ const key = `${args.path}\0${from}`
39
+ const cached = cjsPathCache.get(key)
40
+ if (cached !== undefined) {
41
+ return { path: cached }
42
+ }
30
43
  const resolved = Bun.resolveSync(args.path, from)
31
44
  const cjs = resolved.replace(/\.js$/, '.cjs')
32
- return { path: existsSync(cjs) ? cjs : resolved }
45
+ const path = existsSync(cjs) ? cjs : resolved
46
+ cjsPathCache.set(key, path)
47
+ return { path }
33
48
  })
34
49
  },
35
50
  }
@@ -1,16 +0,0 @@
1
- /*
2
- One DEBUG pattern against one channel name, npm-debug conventions:
3
- `*` matches everything, `abide:*` matches 'abide' and every 'abide:…'
4
- sub-channel, anything else matches exactly. Negation (`-` prefix) is the
5
- caller's concern — patterns arrive here already stripped.
6
- */
7
- export function matchesDebugPattern(name: string, pattern: string): boolean {
8
- if (pattern === '*') {
9
- return true
10
- }
11
- if (pattern.endsWith(':*')) {
12
- const prefix = pattern.slice(0, -2)
13
- return name === prefix || name.startsWith(`${prefix}:`)
14
- }
15
- return pattern === name
16
- }
@@ -1,21 +0,0 @@
1
- /*
2
- DEBUG's comma list, parsed once per distinct string — the gate runs on every
3
- log emission, and the env never changes server-side. A single-slot memo
4
- suffices: the browser's abide-debug localStorage toggle produces a new string
5
- that misses the memo and reparses.
6
- */
7
- let lastEnv: string | undefined
8
- let lastPatterns: string[] = []
9
-
10
- export function parseDebugPatterns(env: string | undefined): string[] {
11
- if (env !== lastEnv) {
12
- lastEnv = env
13
- lastPatterns = env
14
- ? env
15
- .split(',')
16
- .map((raw) => raw.trim())
17
- .filter((pattern) => pattern !== '')
18
- : []
19
- }
20
- return lastPatterns
21
- }