kiru 1.2.0 → 1.2.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 (59) hide show
  1. package/dist/appHandle.d.ts.map +1 -1
  2. package/dist/appHandle.js +4 -5
  3. package/dist/appHandle.js.map +1 -1
  4. package/dist/components/derive.d.ts +1 -1
  5. package/dist/components/derive.d.ts.map +1 -1
  6. package/dist/components/derive.js +1 -1
  7. package/dist/components/derive.js.map +1 -1
  8. package/dist/components/errorBoundary.d.ts +1 -1
  9. package/dist/components/errorBoundary.js +1 -1
  10. package/dist/components/for.d.ts +1 -1
  11. package/dist/components/for.d.ts.map +1 -1
  12. package/dist/components/for.js +1 -1
  13. package/dist/components/lazy.d.ts +1 -1
  14. package/dist/components/lazy.js +1 -1
  15. package/dist/components/portal.d.ts +1 -1
  16. package/dist/components/portal.js +1 -1
  17. package/dist/components/show.d.ts +1 -1
  18. package/dist/components/show.js +1 -1
  19. package/dist/components/transition.d.ts +1 -1
  20. package/dist/components/transition.js +1 -1
  21. package/dist/dom/nodes.d.ts.map +1 -1
  22. package/dist/dom/nodes.js +5 -1
  23. package/dist/dom/nodes.js.map +1 -1
  24. package/dist/dom/props.d.ts.map +1 -1
  25. package/dist/dom/props.js +23 -4
  26. package/dist/dom/props.js.map +1 -1
  27. package/dist/hooks/setup.d.ts +1 -1
  28. package/dist/hooks/setup.d.ts.map +1 -1
  29. package/dist/hooks/setup.js +110 -6
  30. package/dist/hooks/setup.js.map +1 -1
  31. package/dist/scheduler.d.ts.map +1 -1
  32. package/dist/scheduler.js +2 -3
  33. package/dist/scheduler.js.map +1 -1
  34. package/dist/signals/base.d.ts.map +1 -1
  35. package/dist/signals/base.js +23 -4
  36. package/dist/signals/base.js.map +1 -1
  37. package/dist/signals/computed.d.ts.map +1 -1
  38. package/dist/signals/computed.js +9 -1
  39. package/dist/signals/computed.js.map +1 -1
  40. package/dist/utils/vdom.d.ts +2 -1
  41. package/dist/utils/vdom.d.ts.map +1 -1
  42. package/dist/utils/vdom.js +4 -1
  43. package/dist/utils/vdom.js.map +1 -1
  44. package/package.json +1 -1
  45. package/src/appHandle.ts +6 -7
  46. package/src/components/derive.ts +15 -14
  47. package/src/components/errorBoundary.ts +1 -1
  48. package/src/components/for.ts +34 -34
  49. package/src/components/lazy.ts +1 -1
  50. package/src/components/portal.ts +1 -1
  51. package/src/components/show.ts +32 -32
  52. package/src/components/transition.ts +1 -1
  53. package/src/dom/nodes.ts +5 -2
  54. package/src/dom/props.ts +28 -3
  55. package/src/hooks/setup.ts +143 -9
  56. package/src/scheduler.ts +2 -3
  57. package/src/signals/base.ts +21 -5
  58. package/src/signals/computed.ts +9 -1
  59. package/src/utils/vdom.ts +5 -0
package/src/dom/nodes.ts CHANGED
@@ -5,6 +5,7 @@ import { hydrationStack } from "../hydration.js"
5
5
  import {
6
6
  getVNodeApp,
7
7
  isValidTextChild,
8
+ latest,
8
9
  registerVNodeCleanup,
9
10
  } from "../utils/index.js"
10
11
  import { KiruError } from "../error.js"
@@ -27,8 +28,8 @@ function createDom(vNode: DomVNode): SomeDom {
27
28
  t == "#text"
28
29
  ? createTextNode(vNode)
29
30
  : svgTags.has(t)
30
- ? document.createElementNS("http://www.w3.org/2000/svg", t)
31
- : document.createElement(t)
31
+ ? document.createElementNS("http://www.w3.org/2000/svg", t)
32
+ : document.createElement(t)
32
33
 
33
34
  return dom
34
35
  }
@@ -175,6 +176,7 @@ function getOrCreateTextNode(vNode: VNode): MaybeDom {
175
176
  }
176
177
 
177
178
  function subTextNode(vNode: VNode, textNode: Text, signal: Signal<string>) {
179
+ if (__DEV__) signal = latest(signal)
178
180
  const cleanup = signal.subscribe((value, prev) => {
179
181
  if (value === prev) return
180
182
  textNode.nodeValue = value
@@ -198,6 +200,7 @@ function createTextNode(vNode: VNode): Text {
198
200
  }
199
201
 
200
202
  function createSignalTextNode(vNode: VNode, nodeValue: Signal<string>): Text {
203
+ if (__DEV__) nodeValue = latest(nodeValue) as Signal<string>
201
204
  const value = nodeValue.peek() ?? ""
202
205
  const textNode = document.createTextNode(value)
203
206
  subTextNode(vNode, textNode, nodeValue)
package/src/dom/props.ts CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  getVNodeApp,
4
4
  setRef,
5
5
  registerVNodeCleanup,
6
+ latest,
6
7
  } from "../utils/index.js"
7
8
  import { Signal } from "../signals/base.js"
8
9
  import { unwrap } from "../signals/utils.js"
@@ -47,9 +48,33 @@ function updateDomProps(vNode: DomVNode) {
47
48
  const nextProps = props ?? {}
48
49
 
49
50
  if (isTextNode(dom)) {
50
- const nextVal = nextProps.nodeValue
51
- if (!Signal.isSignal(nextVal) && dom.nodeValue !== nextVal) {
52
- dom.nodeValue = nextVal
51
+ let nextVal = nextProps.nodeValue
52
+ if (__DEV__ && Signal.isSignal(nextVal)) nextVal = latest(nextVal)
53
+
54
+ if (!Signal.isSignal(nextVal)) {
55
+ if (dom.nodeValue !== nextVal) {
56
+ dom.nodeValue = nextVal
57
+ }
58
+ return
59
+ }
60
+ if (prevProps.nodeValue === nextVal) return
61
+ dom.nodeValue = String(nextVal.peek() ?? "")
62
+ if (__DEV__) {
63
+ cleanups?.nodeValue?.()
64
+ registerVNodeCleanup(
65
+ vNode,
66
+ "nodeValue",
67
+ nextVal.subscribe((value, prev) => {
68
+ if (value === prev) return
69
+ dom.nodeValue = String(value ?? "")
70
+ if (isBrowser) {
71
+ window.__kiru?.profilingContext?.emit(
72
+ "signalTextUpdate",
73
+ getVNodeApp(vNode)!
74
+ )
75
+ }
76
+ })
77
+ )
53
78
  }
54
79
  return
55
80
  }
@@ -1,8 +1,12 @@
1
- import { computed, signal } from "../signals/index.js"
1
+ import { signal, Signal } from "../signals/base.js"
2
2
  import { createVNodeId } from "../utils/vdom.js"
3
3
  import { __DEV__ } from "../env.js"
4
4
  import { node, setups } from "../globals.js"
5
- import type { Signal } from "../signals/base.js"
5
+ import {
6
+ tracking,
7
+ type TrackingStackObservations,
8
+ } from "../signals/tracking.js"
9
+ import { registerVNodeCleanup } from "../utils/index.js"
6
10
 
7
11
  export interface Setup<Props extends {}> {
8
12
  readonly derive: <T>(
@@ -36,7 +40,6 @@ export function setup<Props extends {}>(): Setup<Props> {
36
40
  setups.set(vNode, setup)
37
41
  return setup
38
42
  }
39
-
40
43
  function createSetup<Props extends {}>(vNode: Kiru.VNode): Setup<Props> {
41
44
  let id: Signal<string>
42
45
 
@@ -45,12 +48,89 @@ function createSetup<Props extends {}>(vNode: Kiru.VNode): Setup<Props> {
45
48
 
46
49
  let prevIndex = -1
47
50
 
48
- return {
49
- derive(selector) {
50
- const props = { ...vNode.props } as InferredProps
51
- const sig = computed(() => selector(props))
52
- propSyncs.push((p) => (sig.value = selector(p)))
53
- return sig
51
+ // Always points at latest props (updated in propSync) so selector and subs see current props
52
+ const currentProps = { current: { ...vNode.props } as InferredProps }
53
+ const deriveCleanups: Array<() => void> = []
54
+
55
+ type DeriveEntry = { run: () => void; accessedPaths: Set<string> }
56
+ const deriveEntries: DeriveEntry[] = []
57
+
58
+ propSyncs.push((p) => {
59
+ const old = currentProps.current as Record<string, unknown>
60
+ const skip = new Set<DeriveEntry>()
61
+ for (const entry of deriveEntries) {
62
+ if (
63
+ entry.accessedPaths.size > 0 &&
64
+ propsUnchangedAtPaths(
65
+ old,
66
+ p as Record<string, unknown>,
67
+ entry.accessedPaths
68
+ )
69
+ ) {
70
+ skip.add(entry)
71
+ }
72
+ }
73
+ currentProps.current = p
74
+ for (const entry of deriveEntries) {
75
+ if (!skip.has(entry)) entry.run()
76
+ }
77
+ })
78
+
79
+ registerVNodeCleanup(vNode, "vnode:setup", () => {
80
+ for (const cleanup of deriveCleanups) cleanup()
81
+ setups.delete(vNode)
82
+ })
83
+
84
+ const setupResult: Setup<Props> = {
85
+ derive<T>(
86
+ selector: (props: Props extends Kiru.FC<infer P> ? P : Props) => T
87
+ ) {
88
+ const resultSig = signal(undefined!) as Signal<T>
89
+ const unsubs = new Map<string, () => void>()
90
+ const accessedPaths = new Set<string>()
91
+
92
+ function sync() {
93
+ accessedPaths.clear()
94
+ const propsProxy = createPropsProxy(
95
+ currentProps.current as Record<string, unknown>,
96
+ accessedPaths
97
+ ) as InferredProps
98
+ const observations: TrackingStackObservations = new Map()
99
+ tracking.stack.push(observations)
100
+ const value = selector(propsProxy)
101
+ tracking.stack.pop()
102
+ // Always assign and notify so the component re-renders when the derived value changes
103
+ // (e.g. when parent passes a different signal ref like toggle switching count/double).
104
+ resultSig.value = value
105
+
106
+ for (const [sid, unsub] of unsubs) {
107
+ if (!observations.has(sid)) {
108
+ unsub()
109
+ unsubs.delete(sid)
110
+ }
111
+ }
112
+ for (const [sid, observedSig] of observations) {
113
+ if (!unsubs.has(sid)) {
114
+ try {
115
+ unsubs.set(sid, observedSig.subscribe(sync))
116
+ } catch {
117
+ // Signal may be disposed after HMR; skip subscribing
118
+ }
119
+ }
120
+ }
121
+ }
122
+
123
+ sync()
124
+ const entry: DeriveEntry = { run: sync, accessedPaths }
125
+ deriveEntries.push(entry)
126
+ deriveCleanups.push(() => {
127
+ unsubs.forEach((u) => u())
128
+ unsubs.clear()
129
+ const i = deriveEntries.indexOf(entry)
130
+ if (i !== -1) deriveEntries.splice(i, 1)
131
+ })
132
+
133
+ return resultSig
54
134
  },
55
135
  get id() {
56
136
  if (!id) {
@@ -67,4 +147,58 @@ function createSetup<Props extends {}>(vNode: Kiru.VNode): Setup<Props> {
67
147
  return id
68
148
  },
69
149
  }
150
+ return setupResult
151
+ }
152
+ function propsUnchangedAtPaths(
153
+ oldProps: Record<string, unknown>,
154
+ newProps: Record<string, unknown>,
155
+ paths: Set<string>
156
+ ): boolean {
157
+ for (const path of paths) {
158
+ if (!Object.is(getAtPath(oldProps, path), getAtPath(newProps, path))) {
159
+ return false
160
+ }
161
+ }
162
+ return true
163
+ }
164
+
165
+ function getAtPath(obj: Record<string, unknown>, path: string): unknown {
166
+ let cur: unknown = obj
167
+ for (const key of path.split(".")) {
168
+ if (cur == null || typeof cur !== "object") return undefined
169
+ cur = (cur as Record<string, unknown>)[key]
170
+ }
171
+ return cur
172
+ }
173
+
174
+ /**
175
+ * Proxy that records paths and wraps signals. We only add to accessedPaths when
176
+ * we hit a signal (the leaf we subscribe to), so propSync skip only compares
177
+ * signal refs. Container objects (e.g. "data") are new every render and would
178
+ * always fail the skip.
179
+ */
180
+ function createPropsProxy<P extends Record<string, unknown>>(
181
+ props: P,
182
+ accessedPaths: Set<string>,
183
+ pathPrefix?: string
184
+ ): P {
185
+ return new Proxy(props, {
186
+ get(holder, key: string) {
187
+ const path = pathPrefix ? `${pathPrefix}.${key}` : key
188
+ const v = holder[key]
189
+ if (Signal.isSignal(v)) {
190
+ accessedPaths.add(path) // only record path for signal leaves
191
+ return v
192
+ }
193
+ if (v !== null && typeof v === "object" && !Array.isArray(v)) {
194
+ return createPropsProxy(
195
+ v as Record<string, unknown>,
196
+ accessedPaths,
197
+ path
198
+ ) as P[keyof P]
199
+ }
200
+ accessedPaths.add(path) // primitive leaf
201
+ return v
202
+ },
203
+ }) as P
70
204
  }
package/src/scheduler.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  findParentErrorBoundary,
23
23
  call,
24
24
  propsChanged,
25
+ depthSort,
25
26
  } from "./utils/index.js"
26
27
  import { __DEV__ } from "./env.js"
27
28
  import { KiruError } from "./error.js"
@@ -129,8 +130,6 @@ function queueDelete(vNode: VNode): void {
129
130
  deletions.push(vNode)
130
131
  }
131
132
 
132
- const depthSort = (a: VNode, b: VNode): number => b.depth - a.depth
133
-
134
133
  let currentWorkRoot: VNode | null = null
135
134
 
136
135
  function doWork(): void {
@@ -402,7 +401,7 @@ function renderFunctionComponent(
402
401
 
403
402
  let newChild = latest(type)(props)
404
403
  if (typeof newChild === "function") {
405
- vNode.subs?.forEach(call) // unsub from signals observered during setup
404
+ vNode.subs?.forEach(call) // unsub from signals observed during setup
406
405
  vNode.render = newChild
407
406
  if (shouldSyncProps) {
408
407
  const p = { ...props }
@@ -5,7 +5,7 @@ import {
5
5
  generateRandomID,
6
6
  registerVNodeCleanup,
7
7
  } from "../utils/index.js"
8
- import { $HMR_ACCEPT, $SIGNAL } from "../constants.js"
8
+ import { $DEV_FILE_LINK, $HMR_ACCEPT, $SIGNAL } from "../constants.js"
9
9
  import { __DEV__, isBrowser } from "../env.js"
10
10
  import { node } from "../globals.js"
11
11
  import { requestUpdate } from "../scheduler.js"
@@ -120,9 +120,19 @@ export class Signal<T> {
120
120
 
121
121
  subscribe(cb: (state: T, prevState?: T) => void): () => void {
122
122
  if (__DEV__) {
123
- const subs = signalSubsMap.get(this.$id)!
124
- subs!.add(cb)
125
- return () => signalSubsMap.get(this.$id)?.delete(cb)
123
+ const tgt = latest(this)
124
+ const subs = signalSubsMap.get(tgt.$id)!
125
+ if (__DEV__ && !subs) {
126
+ const name = tgt.displayName ?? tgt.$id
127
+ let message = `Attempting to subscribe to a signal that has been disposed: ${name}`
128
+ if ($DEV_FILE_LINK in tgt) {
129
+ message += `\nFile: ${tgt[$DEV_FILE_LINK]}`
130
+ }
131
+ message += `\nInitial value: ${tgt.$initialValue}`
132
+ throw new Error(message)
133
+ }
134
+ subs.add(cb)
135
+ return () => subs.delete(cb)
126
136
  }
127
137
  this.$subs!.add(cb)
128
138
  return () => this.$subs!.delete(cb)
@@ -130,7 +140,8 @@ export class Signal<T> {
130
140
 
131
141
  notify(filter?: (sub: SignalSubscriber) => boolean) {
132
142
  if (__DEV__) {
133
- return signalSubsMap.get(this.$id)?.forEach((sub) => {
143
+ const tgt = latest(this)
144
+ return signalSubsMap.get(tgt.$id)?.forEach((sub) => {
134
145
  if (filter && !filter(sub)) return
135
146
  const { $value, $prevValue } = latest(this)
136
147
  return sub($value, $prevValue)
@@ -148,12 +159,14 @@ export class Signal<T> {
148
159
 
149
160
  static subscribers(signal: Signal<any>) {
150
161
  if (__DEV__) {
162
+ signal = latest(signal)
151
163
  return signalSubsMap.get(signal.$id)!
152
164
  }
153
165
  return signal.$subs
154
166
  }
155
167
 
156
168
  static makeReadonly<T>(signal: Signal<T>): ReadonlySignal<T> {
169
+ if (__DEV__) signal = latest(signal)
157
170
  const desc = Object.getOwnPropertyDescriptor(signal, "value")
158
171
  if (desc && !desc.writable) return signal
159
172
  return Object.defineProperty(signal, "value", {
@@ -166,6 +179,7 @@ export class Signal<T> {
166
179
  }
167
180
 
168
181
  static makeWritable<T>(signal: Signal<T>): Signal<T> {
182
+ if (__DEV__) signal = latest(signal)
169
183
  const desc = Object.getOwnPropertyDescriptor(signal, "value")
170
184
  if (desc && desc.writable) return signal
171
185
  return Object.defineProperty(signal, "value", {
@@ -183,6 +197,7 @@ export class Signal<T> {
183
197
 
184
198
  static entangle<T>(signal: Signal<T>) {
185
199
  if (tracking.enabled === false) return
200
+ if (__DEV__) signal = latest(signal)
186
201
 
187
202
  const vNode = node.current
188
203
  const trackedSignalObservations = tracking.current()
@@ -200,6 +215,7 @@ export class Signal<T> {
200
215
  static dispose(signal: Signal<any>) {
201
216
  signal.$isDisposed = true
202
217
  if (__DEV__) {
218
+ signal = latest(signal)
203
219
  signalSubsMap.delete(signal.$id)
204
220
  if (isBrowser) window.__kiru.devtools?.untrack(signal)
205
221
  return
@@ -25,8 +25,16 @@ export class ComputedSignal<T> extends Signal<T> {
25
25
  },
26
26
  inject: (prev) => {
27
27
  inject(prev)
28
+ // Stop any pending reactions on the previous instance and mark
29
+ // this computed as dirty so it will recompute with the latest
30
+ // dependencies after HMR.
28
31
  ComputedSignal.stop(prev)
29
- this.$isDirty = prev.$isDirty
32
+ this.$isDirty = true
33
+ // Force a recompute immediately so any active subscribers (like
34
+ // text nodes bound to a computed signal) see the updated value
35
+ // after a hot reload where only its dependencies changed.
36
+ ComputedSignal.run(this)
37
+ this.notify()
30
38
  },
31
39
  destroy: () => {},
32
40
  } satisfies HMRAccept<ComputedSignal<T>>
package/src/utils/vdom.ts CHANGED
@@ -34,6 +34,7 @@ export {
34
34
  createVNodeId,
35
35
  registerVNodeCleanup,
36
36
  propsChanged,
37
+ depthSort,
37
38
  }
38
39
 
39
40
  function cloneElement(vNode: Kiru.VNode): Kiru.Element {
@@ -219,3 +220,7 @@ function propsChanged(
219
220
  }
220
221
  return false
221
222
  }
223
+
224
+ function depthSort(a: Kiru.VNode, b: Kiru.VNode): number {
225
+ return a.depth - b.depth
226
+ }