@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,367 @@
1
+ import { createSignal, runUntracked } from './reactivity.js'
2
+ import { getCurrentComputation, setCurrentComputation } from './computation-context.js'
3
+
4
+ const createControlFlow = ({
5
+ createOwner,
6
+ runOwnerMounts,
7
+ runWithOwner,
8
+ disposeOwner,
9
+ createBindingEffect,
10
+ renderInOwner,
11
+ getCurrentOwner,
12
+ registerCleanup,
13
+ appendChild,
14
+ flushPendingDescriptors,
15
+ })=> {
16
+ // Reactively replaces DOM content after a comment anchor.
17
+ // getContent() is called inside a binding effect - any signals it reads
18
+ // will trigger a branch switch when they change.
19
+ // On each switch: the previous branch owner is disposed (cleaning up all its
20
+ // effects and event listeners), its DOM nodes are removed, and the new branch
21
+ // is rendered in a fresh child owner.
22
+ const mountDynamic = (anchor, getContent)=> {
23
+ let prevNodes = []
24
+ let prevOwner = null
25
+ // Capture the Either component's owner at call time so branch owners are always
26
+ // parented correctly even when the effect re-runs outside component context.
27
+ const hostOwner = getCurrentOwner()
28
+
29
+ const update = ()=> {
30
+ if (prevOwner){
31
+ disposeOwner(prevOwner)
32
+ prevOwner = null
33
+ }
34
+ prevNodes.forEach(n=> n.remove())
35
+ prevNodes = []
36
+
37
+ const owner = createOwner(hostOwner)
38
+ const prevComp = getCurrentComputation()
39
+ setCurrentComputation(null)
40
+ // NOTE: unlike materializeComponentDescriptor / Loop's renderRow, we do
41
+ // NOT wrap these in runUntracked. getContent reads tracking signals
42
+ // (e.g. the condition for Either/Match) AND invokes lazy branch
43
+ // factories that materialize JSX in the same call. Detaching activeSub
44
+ // here would silence the condition subscription. The auto-parent-link
45
+ // pattern that bites Loop's reused rows is harmless here because
46
+ // disposeOwner(prevOwner) above pre-emptively severs the old branch's
47
+ // effects before each re-run, so purgeDeps has nothing stale to walk.
48
+ const result = runWithOwner(owner, getContent)
49
+ const node = renderInOwner(owner, result ?? null)
50
+ setCurrentComputation(prevComp)
51
+
52
+ // Collect child refs before insertion: DocumentFragment drains on append.
53
+ if (node instanceof DocumentFragment){
54
+ prevNodes = [...node.childNodes]
55
+ } else {
56
+ prevNodes = [node]
57
+ }
58
+
59
+ anchor.after(node)
60
+ prevOwner = owner
61
+
62
+ // For reactive updates (anchor already in live DOM), trigger mount hooks now.
63
+ // For the initial render, renderApp will call runOwnerMounts on the full tree.
64
+ if (anchor.isConnected){
65
+ runOwnerMounts(owner)
66
+ }
67
+ }
68
+
69
+ const stop = createBindingEffect(update)
70
+
71
+ return ()=> {
72
+ if (typeof stop === 'function'){
73
+ stop()
74
+ }
75
+ if (prevOwner){
76
+ disposeOwner(prevOwner)
77
+ prevOwner = null
78
+ }
79
+ prevNodes.forEach(n=> n.remove())
80
+ prevNodes = []
81
+ }
82
+ }
83
+
84
+ // <True> and <False> are transparent slot markers - they pass children through.
85
+ // The Babel plugin wraps them in lazy arrow functions before Either ever sees them,
86
+ // so the inactive branch is never evaluated until needed.
87
+ const True = ({ children })=> children
88
+ const False = ({ children })=> children
89
+ const Case = ({ children })=> children
90
+ const Default = ({ children })=> children
91
+
92
+ // <Either condition={...}>
93
+ // <True>...</True>
94
+ // <False>...</False>
95
+ // </Either>
96
+ //
97
+ // The Babel plugin transforms this into:
98
+ // <Either condition={...} trueBranch={() => <True>...</True>} falseBranch={() => <False>...</False>} />
99
+ // so branches are only rendered when active.
100
+ const Either = ({
101
+ condition, trueBranch, falseBranch,
102
+ })=> {
103
+ const anchor = document.createComment('if')
104
+ // Return a fragment so the anchor and initial branch content land in the
105
+ // parent as siblings. anchor.after() keeps working once in the live DOM.
106
+ const fragment = document.createDocumentFragment()
107
+ fragment.appendChild(anchor)
108
+ const activeTrue = trueBranch
109
+ const activeFalse = falseBranch
110
+
111
+ mountDynamic(anchor, ()=> {
112
+ const cond = typeof condition === 'function' ? condition() : condition
113
+ const branch = cond ? activeTrue : activeFalse
114
+ return branch ? branch() : null
115
+ })
116
+
117
+ return fragment
118
+ }
119
+
120
+ // <Match value={...}>
121
+ // <Case when={...}>...</Case>
122
+ // <Default>...</Default>
123
+ // </Match>
124
+ //
125
+ // The Babel plugin rewrites Case/Default children into lazy factory props:
126
+ // cases=[{ when, branch: () => <Case>...</Case> }, ...]
127
+ // defaultBranch={() => <Default>...</Default>}
128
+ const Match = ({
129
+ value,
130
+ cases = [],
131
+ defaultBranch,
132
+ })=> {
133
+ const anchor = document.createComment('match')
134
+ const fragment = document.createDocumentFragment()
135
+ fragment.appendChild(anchor)
136
+
137
+ const resolve = (source)=> {
138
+ return typeof source === 'function' ? source() : source
139
+ }
140
+
141
+ mountDynamic(anchor, ()=> {
142
+ const activeCases = Array.isArray(cases) ? cases : []
143
+ const valueToMatch = resolve(value)
144
+
145
+ for (const slot of activeCases){
146
+ if (!slot || typeof slot !== 'object'){
147
+ continue
148
+ }
149
+
150
+ let matched = false
151
+ if (Object.prototype.hasOwnProperty.call(slot, 'when')){
152
+ matched = Object.is(valueToMatch, resolve(slot.when))
153
+ }
154
+
155
+ if (!matched){
156
+ continue
157
+ }
158
+
159
+ return typeof slot.branch === 'function' ? slot.branch() : null
160
+ }
161
+
162
+ return typeof defaultBranch === 'function' ? defaultBranch() : null
163
+ })
164
+
165
+ return fragment
166
+ }
167
+
168
+ // <Loop each={items}>{(item, index) => ...}</Loop>
169
+ //
170
+ // Rows are tracked by identity (object reference / primitive value).
171
+ const Loop = ({
172
+ each,
173
+ children,
174
+ })=> {
175
+ const anchor = document.createComment('for')
176
+ const fragment = document.createDocumentFragment()
177
+ fragment.appendChild(anchor)
178
+
179
+ const hostOwner = getCurrentOwner()
180
+ let rows = []
181
+
182
+ const resolveList = ()=> {
183
+ const list = typeof each === 'function' ? each() : each
184
+ return Array.isArray(list) ? list : []
185
+ }
186
+
187
+ const renderRow = (item, indexValue)=> {
188
+ const owner = createOwner(hostOwner)
189
+ const indexSignal = createSignal(indexValue)
190
+ const prevComp = getCurrentComputation()
191
+ setCurrentComputation(null)
192
+ // runUntracked mirrors materializeComponentDescriptor: the row's binding
193
+ // effects belong to its own owner, so they must not auto-link as deps of
194
+ // the surrounding Loop binding effect. Otherwise Loop's purgeDeps on re-run
195
+ // would unwatch them and sever their signal subscriptions.
196
+ const result = runUntracked(()=> runWithOwner(owner, ()=> {
197
+ if (typeof children !== 'function'){
198
+ return null
199
+ }
200
+
201
+ return children(item, indexSignal)
202
+ }))
203
+ const node = runUntracked(()=> renderInOwner(owner, result))
204
+ setCurrentComputation(prevComp)
205
+ const nodes = node instanceof DocumentFragment ? [...node.childNodes] : [node]
206
+
207
+ return {
208
+ owner,
209
+ indexSignal,
210
+ nodes,
211
+ }
212
+ }
213
+
214
+ const mountRows = (nextRows)=> {
215
+ const nodeFragment = document.createDocumentFragment()
216
+ nextRows.forEach((row)=> {
217
+ row.nodes.forEach((node)=> {
218
+ nodeFragment.appendChild(node)
219
+ })
220
+ })
221
+ anchor.before(nodeFragment)
222
+ }
223
+
224
+ const reconcileRows = (nextItems)=> {
225
+ const prevRows = rows
226
+ const nextRows = new Array(nextItems.length)
227
+ const createdRows = []
228
+ const reusedPrevIndices = new Set()
229
+
230
+ // Build lookup: identity -> [prevIndex, ...] (buckets handle duplicates).
231
+ const prevRowsByIdentity = new Map()
232
+ prevRows.forEach((row, prevIndex)=> {
233
+ const bucket = prevRowsByIdentity.get(row.identity)
234
+ if (bucket){
235
+ bucket.push(prevIndex)
236
+ } else {
237
+ prevRowsByIdentity.set(row.identity, [prevIndex])
238
+ }
239
+ })
240
+
241
+ // Match existing rows by identity.
242
+ nextItems.forEach((item, nextIndex)=> {
243
+ const bucket = prevRowsByIdentity.get(item)
244
+ if (!bucket || bucket.length === 0){
245
+ return
246
+ }
247
+
248
+ const prevIndex = bucket.shift()
249
+ nextRows[nextIndex] = prevRows[prevIndex]
250
+ reusedPrevIndices.add(prevIndex)
251
+ })
252
+
253
+ // Create missing rows.
254
+ for (let nextIndex = 0; nextIndex < nextRows.length; nextIndex++){
255
+ const row = nextRows[nextIndex]
256
+ if (row){
257
+ row.indexSignal(nextIndex)
258
+ continue
259
+ }
260
+
261
+ const created = renderRow(nextItems[nextIndex], nextIndex)
262
+ created.identity = nextItems[nextIndex]
263
+ nextRows[nextIndex] = created
264
+ createdRows.push(created)
265
+ }
266
+
267
+ // Dispose rows that were not reused.
268
+ prevRows.forEach((row, prevIndex)=> {
269
+ if (reusedPrevIndices.has(prevIndex)){
270
+ return
271
+ }
272
+
273
+ disposeOwner(row.owner)
274
+ row.nodes.forEach(node=> node.remove())
275
+ })
276
+
277
+ rows = nextRows
278
+ mountRows(rows)
279
+
280
+ if (anchor.isConnected){
281
+ createdRows.forEach((row)=> {
282
+ runOwnerMounts(row.owner)
283
+ })
284
+ }
285
+ }
286
+
287
+ const stop = createBindingEffect(()=> {
288
+ const nextItems = resolveList()
289
+ reconcileRows(nextItems)
290
+ })
291
+
292
+ registerCleanup(()=> {
293
+ if (typeof stop === 'function'){
294
+ stop()
295
+ }
296
+
297
+ rows.forEach((row)=> {
298
+ disposeOwner(row.owner)
299
+ row.nodes.forEach(node=> node.remove())
300
+ })
301
+ rows = []
302
+ })
303
+
304
+ return fragment
305
+ }
306
+
307
+ // <Portal container={el}>...</Portal>
308
+ //
309
+ // Renders children into `container` (defaults to document.body) instead of
310
+ // the component's position in the tree. A comment node is left as a placeholder
311
+ // so the surrounding component tree is not disturbed.
312
+ // All child owners, effects, and event listeners are disposed when the host
313
+ // component unmounts via the registerCleanup call below.
314
+ const Portal = ({ container, children })=> {
315
+ const target = (typeof container === 'function' ? container() : container) ?? document.body
316
+
317
+ const hostOwner = getCurrentOwner()
318
+ const owner = createOwner(hostOwner)
319
+
320
+ const prevComp = getCurrentComputation()
321
+ setCurrentComputation(null)
322
+ const result = runWithOwner(owner, ()=> typeof children === 'function' ? children() : children)
323
+ const node = renderInOwner(owner, result ?? null)
324
+ setCurrentComputation(prevComp)
325
+
326
+ const childCountBefore = target.childNodes.length
327
+
328
+ if (node instanceof DocumentFragment && appendChild){
329
+ appendChild(target, node)
330
+ } else {
331
+ target.appendChild(node)
332
+ }
333
+
334
+ if (flushPendingDescriptors){
335
+ flushPendingDescriptors(target)
336
+ }
337
+
338
+ const portalNodes = [...target.childNodes].slice(childCountBefore)
339
+
340
+ if (target.isConnected){
341
+ runOwnerMounts(owner)
342
+ }
343
+
344
+ registerCleanup(()=> {
345
+ disposeOwner(owner)
346
+ portalNodes.forEach(n=> n.remove())
347
+ })
348
+
349
+ return document.createComment('portal')
350
+ }
351
+
352
+ return {
353
+ mountDynamic,
354
+ Either,
355
+ True,
356
+ False,
357
+ Match,
358
+ Case,
359
+ Default,
360
+ Loop,
361
+ Portal,
362
+ }
363
+ }
364
+
365
+ export {
366
+ createControlFlow,
367
+ }
package/src/index.js ADDED
@@ -0,0 +1,87 @@
1
+ import {
2
+ Case,
3
+ Default,
4
+ Dynamic,
5
+ Either,
6
+ False,
7
+ Fragment,
8
+ Loop,
9
+ Match,
10
+ Portal,
11
+ True,
12
+ createBindingEffect,
13
+ createComputed,
14
+ createContext,
15
+ createSignal,
16
+ createTree,
17
+ h,
18
+ jsx,
19
+ jsxs,
20
+ onMount,
21
+ registerCleanup,
22
+ renderApp,
23
+ useContext,
24
+ } from './jsx-runtime.js'
25
+ import {
26
+ Link,
27
+ NavLink,
28
+ Outlet,
29
+ Route,
30
+ Router,
31
+ lazy,
32
+ navigate,
33
+ useLocation,
34
+ useMatch,
35
+ useNavigate,
36
+ useNavigationState,
37
+ useParams,
38
+ useRoute,
39
+ useSearchParams,
40
+ } from './router.js'
41
+ import { batch } from './reactivity.js'
42
+ import { mergeProps } from './merge-props.js'
43
+ import { createSplitProps, splitProps } from './split-props.js'
44
+
45
+ export {
46
+ Case,
47
+ Default,
48
+ Dynamic,
49
+ False,
50
+ createTree,
51
+ lazy,
52
+ Link,
53
+ NavLink,
54
+ navigate,
55
+ Loop,
56
+ Outlet,
57
+ Fragment,
58
+ Match,
59
+ Either,
60
+ Portal,
61
+ Route,
62
+ Router,
63
+ True,
64
+ useLocation,
65
+ useMatch,
66
+ useNavigate,
67
+ useNavigationState,
68
+ useParams,
69
+ useRoute,
70
+ useSearchParams,
71
+ h,
72
+ jsx,
73
+ jsxs,
74
+ onMount,
75
+ registerCleanup as onCleanup,
76
+ renderApp,
77
+ createBindingEffect as createEffect,
78
+ createComputed,
79
+ createSignal,
80
+ createContext,
81
+ useContext,
82
+ batch,
83
+ mergeProps,
84
+ splitProps,
85
+ createSplitProps,
86
+ }
87
+