@plastic-js/plastic 1.0.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.
@@ -0,0 +1,245 @@
1
+ // Solid-style props proxy. Returns a Proxy that lazily resolves each key from
2
+ // the supplied sources, so getter-defined properties remain reactive: the
3
+ // outer consumer reads `proxy.foo` inside a binding effect, which invokes the
4
+ // getter, which in turn subscribes to any signals it reads.
5
+ //
6
+ // Semantics:
7
+ // - Sources are scanned in argument order. For most keys, the last source
8
+ // that has the key wins (matches Solid).
9
+ // - `class` / `className`: both aliases participate in one merged class
10
+ // stream. Reads of either key see the same concatenated result.
11
+ // - `style`: object values shallow-merge across sources; string values
12
+ // concatenate with `; `; mixing prefers the latest resolved value.
13
+ // - Thunk-valued `class`, `className`, and `style` remain lazy: reads of
14
+ // those keys return a merged accessor thunk that resolves all sources at
15
+ // call time, preserving runtime dependency tracking.
16
+ // - `ref` and `onXxx` event handlers: last source wins (matches Solid).
17
+ // - The proxy is read-only: writes throw.
18
+
19
+ import { isPlainObject } from './utils.js'
20
+
21
+ const CLASS_KEYS = ['class', 'className']
22
+ const STYLE_KEYS = ['style']
23
+ const MAX_MERGE_VALUE_RESOLVE_STEPS = 16
24
+
25
+ const normalizeClassValue = (value)=> {
26
+ if (value == null || value === false || value === ''){
27
+ return undefined
28
+ }
29
+ if (typeof value !== 'string'){
30
+ return undefined
31
+ }
32
+ return value
33
+ }
34
+
35
+ const isClassKey = key=> key === 'class' || key === 'className'
36
+
37
+ const resolveThunkValue = (value)=> {
38
+ let resolved = value
39
+ let steps = 0
40
+ while (typeof resolved === 'function' && steps < MAX_MERGE_VALUE_RESOLVE_STEPS){
41
+ resolved = resolved()
42
+ steps += 1
43
+ }
44
+
45
+ return resolved
46
+ }
47
+
48
+ // Concatenate only string-valued class sources. If any source carries a
49
+ // function or signal (a reactive accessor for the class string), it would not
50
+ // be safe to coerce via String(...) — that would emit the function's source
51
+ // code. Fall back to last-wins so the consumer's `resolveReactiveValue` can
52
+ // unwrap it normally.
53
+ const mergeClassValues = (values)=> {
54
+ const hasNonString = values.some(value=> value != null && typeof value !== 'string' && value !== false)
55
+ if (hasNonString){
56
+ return values[values.length - 1]
57
+ }
58
+ const parts = values.map(normalizeClassValue).filter(Boolean)
59
+ return parts.length ? parts.join(' ') : undefined
60
+ }
61
+
62
+ const mergeStyleValues = (values)=> {
63
+ let result
64
+ for (const value of values){
65
+ if (value == null){
66
+ continue
67
+ }
68
+ if (isPlainObject(result) && isPlainObject(value)){
69
+ result = { ...result, ...value }
70
+ } else if (typeof result === 'string' && typeof value === 'string'){
71
+ result = `${result}; ${value}`
72
+ } else {
73
+ result = value
74
+ }
75
+ }
76
+ return result
77
+ }
78
+
79
+
80
+ // Sources can be either plain objects or zero-arg functions ("thunks") that
81
+ // the Babel plugin emits for dynamic spread sources like `{...api()}`. The
82
+ // thunk is invoked on every access so signal reads inside `api()` are tracked
83
+ // by whatever effect is currently consuming the proxy.
84
+ const resolveSource = source=> typeof source === 'function' ? source() : source
85
+
86
+ const collectPresentKeys = (source, keys)=> {
87
+ const matches = []
88
+ const seen = new Set()
89
+ for (const key of Reflect.ownKeys(source)){
90
+ if (typeof key !== 'string' || !keys.includes(key) || seen.has(key)){
91
+ continue
92
+ }
93
+ seen.add(key)
94
+ matches.push(key)
95
+ }
96
+
97
+ for (const key of keys){
98
+ if (seen.has(key) || !(key in source)){
99
+ continue
100
+ }
101
+ matches.push(key)
102
+ }
103
+
104
+ return matches
105
+ }
106
+
107
+ // Collect each source's value for a key family in source order, invoking any
108
+ // getter (which is where signal-tracking happens for reactive props).
109
+ const collectValues = (sources, keys)=> {
110
+ const values = []
111
+ for (const source of sources){
112
+ const resolved = resolveSource(source)
113
+ if (resolved == null){
114
+ continue
115
+ }
116
+ for (const key of collectPresentKeys(resolved, keys)){
117
+ values.push(resolved[key])
118
+ }
119
+ }
120
+ return values
121
+ }
122
+
123
+ const getKeyFamily = (key)=> {
124
+ if (isClassKey(key)){
125
+ return CLASS_KEYS
126
+ }
127
+ if (key === 'style'){
128
+ return STYLE_KEYS
129
+ }
130
+ return [key]
131
+ }
132
+
133
+ const resolveKey = (sources, key)=> {
134
+ const values = collectValues(sources, getKeyFamily(key))
135
+ if (values.length === 0){
136
+ return undefined
137
+ }
138
+
139
+ if (key === 'class' || key === 'className'){
140
+ if (values.some(value=> typeof value === 'function')){
141
+ // Keep merged class values lazy so runtime consumers can resolve the
142
+ // accessor inside their own tracking scope instead of subscribing here.
143
+ return ()=> mergeClassValues(values.map(resolveThunkValue))
144
+ }
145
+ return mergeClassValues(values)
146
+ }
147
+
148
+ if (key === 'style'){
149
+ if (values.some(value=> typeof value === 'function')){
150
+ // Style follows the same rule as class: preserve thunk semantics so
151
+ // updates track at the eventual DOM-binding read site.
152
+ return ()=> mergeStyleValues(values.map(resolveThunkValue))
153
+ }
154
+ return mergeStyleValues(values)
155
+ }
156
+
157
+ return values[values.length - 1]
158
+ }
159
+
160
+ const hasKey = (sources, key)=> {
161
+ const keys = getKeyFamily(key)
162
+ for (const source of sources){
163
+ const resolved = resolveSource(source)
164
+ if (resolved == null){
165
+ continue
166
+ }
167
+ if (keys.some(candidate=> candidate in resolved)){
168
+ return true
169
+ }
170
+ }
171
+ return false
172
+ }
173
+
174
+ const getCanonicalClassKey = (sources)=> {
175
+ let canonicalKey
176
+ for (const source of sources){
177
+ const resolved = resolveSource(source)
178
+ if (resolved == null){
179
+ continue
180
+ }
181
+ for (const key of collectPresentKeys(resolved, CLASS_KEYS)){
182
+ canonicalKey = key
183
+ }
184
+ }
185
+ return canonicalKey
186
+ }
187
+
188
+ const collectKeys = (sources)=> {
189
+ const seen = new Set()
190
+ const keys = []
191
+ let hasClassAlias = false
192
+ for (const source of sources){
193
+ const resolved = resolveSource(source)
194
+ if (resolved == null){
195
+ continue
196
+ }
197
+ for (const key of Reflect.ownKeys(resolved)){
198
+ if (isClassKey(key)){
199
+ hasClassAlias = true
200
+ continue
201
+ }
202
+ if (!seen.has(key)){
203
+ seen.add(key)
204
+ keys.push(key)
205
+ }
206
+ }
207
+ }
208
+ if (hasClassAlias){
209
+ // Expose one canonical class key during enumeration so reflection APIs
210
+ // (`Object.assign`, `Object.entries`) don't duplicate `class`/`className`.
211
+ keys.push(getCanonicalClassKey(sources) ?? 'class')
212
+ }
213
+ return keys
214
+ }
215
+
216
+ const readOnlyTrap = ()=> {
217
+ throw new Error('mergeProps result is read-only')
218
+ }
219
+
220
+ const IS_MERGED_PROPS = Symbol('mergeProps')
221
+
222
+ export const mergeProps = (...sources)=> {
223
+ return new Proxy({}, {
224
+ get: (_, key)=> {
225
+ if (key === IS_MERGED_PROPS) return true
226
+ return resolveKey(sources, key)
227
+ },
228
+ has: (_, key)=> hasKey(sources, key),
229
+ ownKeys: ()=> collectKeys(sources),
230
+ getOwnPropertyDescriptor: (_, key)=> {
231
+ if (!hasKey(sources, key)){
232
+ return undefined
233
+ }
234
+ return {
235
+ enumerable: true,
236
+ configurable: true,
237
+ get: ()=> resolveKey(sources, key),
238
+ }
239
+ },
240
+ set: readOnlyTrap,
241
+ deleteProperty: readOnlyTrap,
242
+ })
243
+ }
244
+
245
+ export const isMergedProps = (value)=> value != null && typeof value === 'object' && value[IS_MERGED_PROPS] === true
@@ -0,0 +1,408 @@
1
+ /**
2
+ * reactivity.js — Reactive system for the Plastic framework.
3
+ *
4
+ * Built on top of the `alien-signals` library, which provides fine-grained
5
+ * signal primitives (`signal`, `computed`, `effect`) suited for primitive values.
6
+ * This module extends that foundation with deep object reactivity.
7
+ *
8
+ * ## Core concepts
9
+ *
10
+ * ### Signals (`createSignal`)
11
+ * A thin public wrapper around `alien-signals`'s `signal()`. Calling
12
+ * `createSignal(x)` on an already-signal value is a no-op — the original
13
+ * signal is returned unchanged.
14
+ *
15
+ * ### Reactive trees (`tree` / `createTree`)
16
+ * `tree(obj)` wraps a plain object (or array) in an ES Proxy that makes every
17
+ * property access and mutation reactive. It is conceptually equivalent to
18
+ * Vue 3's `reactive()`.
19
+ *
20
+ * Key implementation details:
21
+ * - **Per-property signals**: Each accessed property is lazily backed by an
22
+ * `alien-signals` signal stored in a `signals` map (keyed by property name
23
+ * or symbol). Reads subscribe the current effect; writes trigger updates.
24
+ * - **Proxy cache**: A `WeakMap` (proxyCache) ensures that wrapping the same
25
+ * raw object multiple times always returns the same proxy, preventing
26
+ * duplicate subscriptions.
27
+ * - **`RAW` / `IS_TREE` sentinels**: Two well-known symbols allow consumers to
28
+ * unwrap to the original object (`toRaw`) and to test whether a value is
29
+ * already a reactive tree (`isTree`), avoiding double-wrapping.
30
+ * - **Iterate tracking**: A dedicated `ITERATE_KEY` signal is used to track
31
+ * structural changes (property addition/deletion, array length changes).
32
+ * Operations like `for…in`, `Object.keys`, and spread trigger this signal so
33
+ * effects that iterate over an object re-run when its shape changes.
34
+ * - **Non-trackable keys**: Built-in symbols (`Symbol.iterator`, etc.) and a
35
+ * small set of Vue-compatibility keys are excluded from tracking to avoid
36
+ * spurious subscriptions.
37
+ * - **Array instrumentations**: Mutating array methods (`push`, `pop`, `shift`,
38
+ * `unshift`, `splice`) temporarily pause dependency tracking while executing
39
+ * to prevent the read of `length` inside those methods from creating
40
+ * unintended subscriptions. Search methods (`includes`, `indexOf`,
41
+ * `lastIndexOf`) explicitly track all indices and also fall back to comparing
42
+ * raw (unwrapped) values, supporting reactive proxies as search arguments.
43
+ * - **Nested reactivity**: When `get` returns an object value it is recursively
44
+ * wrapped with `tree()`, providing deep reactivity on demand.
45
+ * - **Raw value storage**: `set` always unwraps values through `toRaw` before
46
+ * writing to the underlying target, keeping raw objects free of proxy
47
+ * references and preventing double-wrapping in the signal store.
48
+ *
49
+ * ### Tracking pause/resume
50
+ * `pauseTracking` / `resumeTracking` manipulate `alien-signals`'s active
51
+ * subscriber stack via `getActiveSub` / `setActiveSub`, temporarily
52
+ * suspending dependency collection for mutation-only code paths.
53
+ *
54
+ * ## Public API
55
+ * Re-exports from `alien-signals`: `computed`, `effect`, `isSignal`, `isComputed`
56
+ * Added by this module: `tree`, `createSignal`, `createTree`, `isTree`, `toRaw`
57
+ */
58
+
59
+ import {
60
+ computed, effect as originalEffect, endBatch, getActiveSub, isComputed as originalIsComputed, isSignal as originalIsSignal, setActiveSub, signal, startBatch,
61
+ } from 'alien-signals'
62
+
63
+ // alien-signals 3.x treats the effect callback's return value as a cleanup
64
+ // function. Most callers return non-function values (e.g. `log.push(x)` →
65
+ // number), which crash on re-run. Discard non-function returns.
66
+ const effect = (fn)=> originalEffect(()=> {
67
+ const result = fn()
68
+ return typeof result === 'function' ? result : undefined
69
+ })
70
+ import { isObject } from './utils.js'
71
+
72
+ const RAW = Symbol('raw')
73
+ const IS_TREE = Symbol('isTree')
74
+ const ITERATE_KEY = Symbol('iterate')
75
+ const proxyCache = new WeakMap()
76
+ const nonTrackableKeys = new Set([
77
+ '__proto__',
78
+ '__v_isRef',
79
+ '__isVue',
80
+ ])
81
+ const builtInSymbols = new Set(Object.getOwnPropertyNames(Symbol)
82
+ .map(key=> Symbol[key])
83
+ .filter(symbol=> typeof symbol === 'symbol'))
84
+
85
+ const isSignal = value=> {
86
+ if (typeof value === 'function' && originalIsSignal(value)){
87
+ return true
88
+ }
89
+ return false
90
+ }
91
+
92
+ const isComputed = value=> {
93
+ if (typeof value === 'function' && originalIsComputed(value)){
94
+ return true
95
+ }
96
+ return false
97
+ }
98
+
99
+ const isTrackableKey = (key)=> {
100
+ if (typeof key === 'symbol'){
101
+ return !builtInSymbols.has(key)
102
+ }
103
+ if (typeof key !== 'string'){
104
+ return false
105
+ }
106
+ return !nonTrackableKeys.has(key)
107
+ }
108
+
109
+ const isIntegerKey = (key)=> {
110
+ if (typeof key === 'number'){
111
+ return Number.isInteger(key) && key >= 0
112
+ }
113
+ if (typeof key !== 'string' || key === 'NaN' || key[0] === '-'){
114
+ return false
115
+ }
116
+ const parsed = Number(key)
117
+ return Number.isInteger(parsed) && parsed >= 0 && `${parsed}` === key
118
+ }
119
+
120
+ const tree = (obj)=> {
121
+ if (!isObject(obj)){
122
+ return obj
123
+ }
124
+ if (obj[RAW]){
125
+ return obj
126
+ }
127
+ if (proxyCache.has(obj)){
128
+ return proxyCache.get(obj)
129
+ }
130
+
131
+ const signals = Object.create(null)
132
+ const hasSignal = key=> Object.hasOwn(signals, key)
133
+ const isArrayTarget = Array.isArray(obj)
134
+ let iterateVersion = 0
135
+ const trackKey = (target, key, receiver)=> {
136
+ if (!isTrackableKey(key)){
137
+ return Reflect.get(target, key, receiver)
138
+ }
139
+ const currentValue = Reflect.get(target, key, receiver)
140
+ if (!hasSignal(key)){
141
+ signals[key] = signal(currentValue)
142
+ }
143
+ return signals[key]()
144
+ }
145
+ const triggerKey = (key, value)=> {
146
+ if (!isTrackableKey(key)){
147
+ if (hasSignal(key)){
148
+ signals[key](value)
149
+ }
150
+ return
151
+ }
152
+ if (!hasSignal(key)){
153
+ signals[key] = signal(value)
154
+ return
155
+ }
156
+ signals[key](value)
157
+ }
158
+ const prevSubStack = []
159
+ const pauseTracking = ()=> {
160
+ prevSubStack.push(getActiveSub())
161
+ setActiveSub(undefined)
162
+ }
163
+ const resumeTracking = ()=> {
164
+ setActiveSub(prevSubStack.pop())
165
+ }
166
+ const trackIterate = ()=> {
167
+ if (!hasSignal(ITERATE_KEY)){
168
+ signals[ITERATE_KEY] = signal(iterateVersion)
169
+ }
170
+ signals[ITERATE_KEY]()
171
+ }
172
+ const triggerIterate = ()=> {
173
+ iterateVersion += 1
174
+ if (!hasSignal(ITERATE_KEY)){
175
+ signals[ITERATE_KEY] = signal(iterateVersion)
176
+ return
177
+ }
178
+ signals[ITERATE_KEY](iterateVersion)
179
+ }
180
+
181
+ let arrayInstrumentations = null
182
+ if(isArrayTarget){
183
+ arrayInstrumentations = {
184
+ includes(...args){
185
+ trackKey(obj, 'length')
186
+ for (let i = 0; i < obj.length; i++){
187
+ trackKey(obj, `${i}`)
188
+ }
189
+ const rawResult = Array.prototype.includes.apply(obj, args)
190
+ return rawResult || Array.prototype.includes.apply(obj, args.map(toRaw))
191
+ },
192
+ indexOf(...args){
193
+ trackKey(obj, 'length')
194
+ for (let i = 0; i < obj.length; i++){
195
+ trackKey(obj, `${i}`)
196
+ }
197
+ const rawResult = Array.prototype.indexOf.apply(obj, args)
198
+ if (rawResult !== -1){
199
+ return rawResult
200
+ }
201
+ return Array.prototype.indexOf.apply(obj, args.map(toRaw))
202
+ },
203
+ lastIndexOf(...args){
204
+ trackKey(obj, 'length')
205
+ for (let i = 0; i < obj.length; i++){
206
+ trackKey(obj, `${i}`)
207
+ }
208
+ const rawResult = Array.prototype.lastIndexOf.apply(obj, args)
209
+ if (rawResult !== -1){
210
+ return rawResult
211
+ }
212
+ return Array.prototype.lastIndexOf.apply(obj, args.map(toRaw))
213
+ },
214
+ push(...args){
215
+ pauseTracking()
216
+ try {
217
+ return Array.prototype.push.apply(proxy, args.map(toRaw))
218
+ } finally {
219
+ resumeTracking()
220
+ }
221
+ },
222
+ pop(...args){
223
+ pauseTracking()
224
+ try {
225
+ return Array.prototype.pop.apply(proxy, args)
226
+ } finally {
227
+ resumeTracking()
228
+ }
229
+ },
230
+ shift(...args){
231
+ pauseTracking()
232
+ try {
233
+ return Array.prototype.shift.apply(proxy, args)
234
+ } finally {
235
+ resumeTracking()
236
+ }
237
+ },
238
+ unshift(...args){
239
+ pauseTracking()
240
+ try {
241
+ return Array.prototype.unshift.apply(proxy, args.map(toRaw))
242
+ } finally {
243
+ resumeTracking()
244
+ }
245
+ },
246
+ splice(...args){
247
+ pauseTracking()
248
+ try {
249
+ const normalized = args.map((arg, index)=> index < 2 ? arg : toRaw(arg))
250
+ return Array.prototype.splice.apply(proxy, normalized)
251
+ } finally {
252
+ resumeTracking()
253
+ }
254
+ },
255
+ }
256
+ }
257
+
258
+ const proxy = new Proxy(obj, {
259
+ get(target, key, receiver){
260
+ if (key === RAW){
261
+ return target
262
+ }
263
+ if (key === IS_TREE){
264
+ return true
265
+ }
266
+ if (isArrayTarget && typeof key === 'string'){
267
+ if (arrayInstrumentations && Object.hasOwn(arrayInstrumentations, key)){
268
+ return arrayInstrumentations[key]
269
+ }
270
+ if (typeof target[key] === 'function'){
271
+ return Reflect.get(target, key, receiver)
272
+ }
273
+ }
274
+ if (!isTrackableKey(key)){
275
+ return Reflect.get(target, key, receiver)
276
+ }
277
+ const value = trackKey(target, key, receiver)
278
+ if (isObject(value)){
279
+ return tree(value)
280
+ }
281
+ return value
282
+ },
283
+ set(target, key, value, receiver){
284
+ const oldLength = isArrayTarget ? target.length : 0
285
+ const isLengthKey = isArrayTarget && key === 'length'
286
+ const isIndexKey = isArrayTarget && isIntegerKey(key)
287
+ const hadKey = Object.hasOwn(target, key)
288
+ const rawValue = toRaw(value)
289
+ const setOk = Reflect.set(target, key, rawValue, receiver)
290
+ if (!setOk){
291
+ return false
292
+ }
293
+ const nextValue = Reflect.get(target, key, receiver)
294
+ if (!isTrackableKey(key)){
295
+ triggerKey(key, nextValue)
296
+ if (!hadKey){
297
+ triggerIterate()
298
+ }
299
+ return true
300
+ }
301
+ triggerKey(key, nextValue)
302
+ if (isLengthKey){
303
+ const newLength = target.length
304
+ if (newLength < oldLength){
305
+ for (let i = newLength; i < oldLength; i++){
306
+ triggerKey(String(i), undefined)
307
+ }
308
+ triggerIterate()
309
+ }
310
+ }
311
+ if (isIndexKey && Number(key) >= oldLength){
312
+ triggerKey('length', target.length)
313
+ }
314
+ if (!hadKey){
315
+ triggerIterate()
316
+ }
317
+ return true
318
+ },
319
+ has(target, key){
320
+ if (key === IS_TREE){ return true }
321
+ if (key === RAW){ return key in target }
322
+ if (!isTrackableKey(key)){
323
+ return key in target
324
+ }
325
+ trackKey(target, key)
326
+ return key in target
327
+ },
328
+ ownKeys(target){
329
+ trackIterate()
330
+ return Reflect.ownKeys(target)
331
+ },
332
+ deleteProperty(target, key){
333
+ const hadKey = Object.hasOwn(target, key)
334
+ const deleted = Reflect.deleteProperty(target, key)
335
+ if (deleted && hadKey){
336
+ if (hasSignal(key)){
337
+ signals[key](undefined)
338
+ }
339
+ triggerIterate()
340
+ }
341
+ return deleted
342
+ },
343
+ })
344
+
345
+ proxyCache.set(obj, proxy)
346
+ return proxy
347
+ }
348
+
349
+ // Component bodies must not subscribe to the reactive context above them. If
350
+ // the materializer happens to run inside an outer effect (e.g. a router
351
+ // outlet), every signal read by the component would re-trigger that outer
352
+ // effect — re-mounting the whole subtree on every unrelated state change.
353
+ // Internal binding effects/computations create their own active subscribers,
354
+ // so suppressing the outer one here only affects bare signal reads in the
355
+ // component body.
356
+ const runUntracked = (fn)=> {
357
+ const prevSub = getActiveSub()
358
+ setActiveSub(undefined)
359
+ try {
360
+ return fn()
361
+ } finally {
362
+ setActiveSub(prevSub)
363
+ }
364
+ }
365
+
366
+ const createSignal = (value)=> {
367
+ if (isSignal(value)){
368
+ return value
369
+ }
370
+
371
+ if (isComputed(value)){
372
+ console.warn('[reactivity] createSignal: wrapping a computed in a signal is redundant, use the computed directly.')
373
+ }
374
+
375
+ return signal(value)
376
+ }
377
+
378
+ const createTree = (value)=> {
379
+ if (!isObject(value)){
380
+ return value
381
+ }
382
+
383
+ if (isTree(value)){
384
+ return value
385
+ }
386
+
387
+ return tree(value)
388
+ }
389
+
390
+ const isTree = value=> isObject(value) && value[IS_TREE] === true
391
+
392
+ const toRaw = (value)=> {
393
+ const raw = isObject(value) && value[RAW]
394
+ return raw ? toRaw(raw) : value
395
+ }
396
+
397
+ const batch = (fn)=> {
398
+ startBatch()
399
+ try {
400
+ return fn()
401
+ } finally {
402
+ endBatch()
403
+ }
404
+ }
405
+
406
+ export {
407
+ batch, effect, runUntracked, isComputed, isSignal, isTree, toRaw, createSignal, createTree, computed as createComputed,
408
+ }