@fictjs/runtime 0.0.2

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 (51) hide show
  1. package/README.md +17 -0
  2. package/dist/index.cjs +4224 -0
  3. package/dist/index.cjs.map +1 -0
  4. package/dist/index.d.cts +1572 -0
  5. package/dist/index.d.ts +1572 -0
  6. package/dist/index.dev.js +4240 -0
  7. package/dist/index.dev.js.map +1 -0
  8. package/dist/index.js +4133 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/jsx-dev-runtime.cjs +44 -0
  11. package/dist/jsx-dev-runtime.cjs.map +1 -0
  12. package/dist/jsx-dev-runtime.js +14 -0
  13. package/dist/jsx-dev-runtime.js.map +1 -0
  14. package/dist/jsx-runtime.cjs +44 -0
  15. package/dist/jsx-runtime.cjs.map +1 -0
  16. package/dist/jsx-runtime.js +14 -0
  17. package/dist/jsx-runtime.js.map +1 -0
  18. package/dist/slim.cjs +3384 -0
  19. package/dist/slim.cjs.map +1 -0
  20. package/dist/slim.d.cts +475 -0
  21. package/dist/slim.d.ts +475 -0
  22. package/dist/slim.js +3335 -0
  23. package/dist/slim.js.map +1 -0
  24. package/package.json +68 -0
  25. package/src/binding.ts +2127 -0
  26. package/src/constants.ts +456 -0
  27. package/src/cycle-guard.ts +134 -0
  28. package/src/devtools.ts +17 -0
  29. package/src/dom.ts +683 -0
  30. package/src/effect.ts +83 -0
  31. package/src/error-boundary.ts +118 -0
  32. package/src/hooks.ts +72 -0
  33. package/src/index.ts +184 -0
  34. package/src/jsx-dev-runtime.ts +2 -0
  35. package/src/jsx-runtime.ts +2 -0
  36. package/src/jsx.ts +786 -0
  37. package/src/lifecycle.ts +273 -0
  38. package/src/list-helpers.ts +619 -0
  39. package/src/memo.ts +14 -0
  40. package/src/node-ops.ts +185 -0
  41. package/src/props.ts +212 -0
  42. package/src/reconcile.ts +151 -0
  43. package/src/ref.ts +25 -0
  44. package/src/scheduler.ts +12 -0
  45. package/src/signal.ts +1278 -0
  46. package/src/slim.ts +68 -0
  47. package/src/store.ts +210 -0
  48. package/src/suspense.ts +187 -0
  49. package/src/transition.ts +128 -0
  50. package/src/types.ts +172 -0
  51. package/src/versioned-signal.ts +58 -0
package/src/slim.ts ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Slim fine-grained runtime entry for bundle-sensitive builds.
3
+ *
4
+ * Exposes only the DOM + signals surface required by compiler output and
5
+ * leaves out stores, resources, SSR, devtools, etc.
6
+ */
7
+
8
+ // Reactive primitives
9
+ export { createSignal, createSelector, $state } from './signal'
10
+ export { createMemo } from './memo'
11
+ export { createEffect, createRenderEffect } from './effect'
12
+ export { batch, untrack } from './scheduler'
13
+
14
+ // DOM rendering
15
+ export { render, template, createElement } from './dom'
16
+ export { Fragment } from './jsx'
17
+
18
+ // Core bindings used by compiler output
19
+ export {
20
+ insert,
21
+ bindText,
22
+ bindAttribute,
23
+ bindProperty,
24
+ bindClass,
25
+ bindStyle,
26
+ bindEvent,
27
+ bindRef,
28
+ createConditional,
29
+ createList,
30
+ delegateEvents,
31
+ clearDelegatedEvents,
32
+ } from './binding'
33
+
34
+ // Keyed list helpers (fine-grained DOM)
35
+ export {
36
+ createKeyedListContainer,
37
+ createKeyedList,
38
+ createKeyedBlock,
39
+ moveMarkerBlock,
40
+ destroyMarkerBlock,
41
+ insertNodesBefore,
42
+ removeNodes,
43
+ toNodeArray,
44
+ getFirstNodeAfter,
45
+ } from './list-helpers'
46
+
47
+ // Minimal hooks surface for builds that rely on hook helpers
48
+ export {
49
+ __fictUseContext,
50
+ __fictUseSignal,
51
+ __fictUseMemo,
52
+ __fictUseEffect,
53
+ __fictRender,
54
+ __fictPushContext,
55
+ __fictPopContext,
56
+ __fictResetContext,
57
+ } from './hooks'
58
+
59
+ // Props helpers (kept minimal for compatibility with compiler output)
60
+ export {
61
+ __fictProp,
62
+ __fictProp as prop,
63
+ __fictPropsRest,
64
+ mergeProps,
65
+ useProp,
66
+ createPropsProxy,
67
+ } from './props'
68
+ export { onDestroy } from './lifecycle'
package/src/store.ts ADDED
@@ -0,0 +1,210 @@
1
+ import { signal, batch, type SignalAccessor } from './signal'
2
+
3
+ const PROXY = Symbol('fict:store-proxy')
4
+ const TARGET = Symbol('fict:store-target')
5
+
6
+ // ============================================================================
7
+ // Store (Deep Proxy)
8
+ // ============================================================================
9
+
10
+ export type Store<T> = T
11
+
12
+ /**
13
+ * Create a Store: a reactive proxy that allows fine-grained access and mutation.
14
+ *
15
+ * @param initialValue - The initial state object
16
+ * @returns [store, setStore]
17
+ */
18
+ export function createStore<T extends object>(
19
+ initialValue: T,
20
+ ): [Store<T>, (fn: (state: T) => void | T) => void] {
21
+ const unwrapped = unwrap(initialValue)
22
+ const wrapped = wrap(unwrapped)
23
+
24
+ function setStore(fn: (state: T) => void | T) {
25
+ batch(() => {
26
+ const result = fn(wrapped)
27
+ if (result !== undefined) {
28
+ reconcile(wrapped, result)
29
+ }
30
+ })
31
+ }
32
+
33
+ return [wrapped, setStore]
34
+ }
35
+
36
+ // Map of target object -> Proxy
37
+ const proxyCache = new WeakMap<object, any>()
38
+ // Map of target object -> Map<key, Signal>
39
+ const signalCache = new WeakMap<object, Map<string | symbol, SignalAccessor<any>>>()
40
+
41
+ function wrap<T>(value: T): T {
42
+ if (value === null || typeof value !== 'object') return value
43
+ if ((value as any)[PROXY]) return value
44
+
45
+ if (proxyCache.has(value)) return proxyCache.get(value)
46
+
47
+ const handler: ProxyHandler<any> = {
48
+ get(target, prop, receiver) {
49
+ if (prop === PROXY) return true
50
+ if (prop === TARGET) return target
51
+
52
+ const value = Reflect.get(target, prop, receiver)
53
+
54
+ // Track property access
55
+ track(target, prop)
56
+
57
+ // Recursively wrap objects
58
+ return wrap(value)
59
+ },
60
+ set(target, prop, value, receiver) {
61
+ if (prop === PROXY || prop === TARGET) return false
62
+
63
+ const oldValue = Reflect.get(target, prop, receiver)
64
+ if (oldValue === value) return true
65
+
66
+ const result = Reflect.set(target, prop, value, receiver)
67
+ if (result) {
68
+ trigger(target, prop)
69
+ }
70
+ return result
71
+ },
72
+ deleteProperty(target, prop) {
73
+ const result = Reflect.deleteProperty(target, prop)
74
+ if (result) {
75
+ trigger(target, prop)
76
+ }
77
+ return result
78
+ },
79
+ }
80
+
81
+ const proxy = new Proxy(value, handler)
82
+ proxyCache.set(value, proxy)
83
+ return proxy as T
84
+ }
85
+
86
+ function unwrap<T>(value: T): T {
87
+ if (value && typeof value === 'object' && (value as any)[PROXY]) {
88
+ return (value as any)[TARGET]
89
+ }
90
+ return value
91
+ }
92
+
93
+ function track(target: object, prop: string | symbol) {
94
+ let signals = signalCache.get(target)
95
+ if (!signals) {
96
+ signals = new Map()
97
+ signalCache.set(target, signals)
98
+ }
99
+
100
+ let s = signals.get(prop)
101
+ if (!s) {
102
+ s = signal(getLastValue(target, prop))
103
+ signals.set(prop, s)
104
+ }
105
+ s() // subscribe
106
+ }
107
+
108
+ function trigger(target: object, prop: string | symbol) {
109
+ const signals = signalCache.get(target)
110
+ if (signals) {
111
+ const s = signals.get(prop)
112
+ if (s) {
113
+ s(getLastValue(target, prop)) // notify with new value
114
+ }
115
+ }
116
+ }
117
+
118
+ function getLastValue(target: any, prop: string | symbol) {
119
+ return target[prop]
120
+ }
121
+
122
+ /**
123
+ * Reconcile a store path with a new value (shallow merge/diff)
124
+ */
125
+ function reconcile(target: any, value: any) {
126
+ if (target === value) return
127
+ if (value === null || typeof value !== 'object') return // Should replace?
128
+
129
+ const realTarget = unwrap(target)
130
+ const realValue = unwrap(value)
131
+
132
+ const keys = new Set([...Object.keys(realTarget), ...Object.keys(realValue)])
133
+ for (const key of keys) {
134
+ if (realValue[key] === undefined && realTarget[key] !== undefined) {
135
+ // deleted
136
+ delete target[key] // Triggers proxy trap
137
+ } else if (realTarget[key] !== realValue[key]) {
138
+ target[key] = realValue[key] // Triggers proxy trap
139
+ }
140
+ }
141
+ }
142
+
143
+ // ============================================================================
144
+ // Diffing Signal (for List Items)
145
+ // ============================================================================
146
+
147
+ /**
148
+ * Creates a signal that returns a Stable Proxy.
149
+ * Updates to the signal (via set) will diff the new value against the old value
150
+ * and trigger property-specific updates.
151
+ */
152
+ export function createDiffingSignal<T extends object>(initialValue: T) {
153
+ let currentValue = unwrap(initialValue)
154
+ const signals = new Map<string | symbol, SignalAccessor<any>>()
155
+
156
+ // The stable proxy we return
157
+ const proxy = new Proxy({} as T, {
158
+ get(_, prop) {
159
+ if (prop === PROXY) return true
160
+ if (prop === TARGET) return currentValue
161
+
162
+ // Subscribe to property
163
+ let s = signals.get(prop)
164
+ if (!s) {
165
+ // Initialize signal with current property value
166
+ s = signal((currentValue as any)[prop])
167
+ signals.set(prop, s)
168
+ }
169
+ return s()
170
+ },
171
+ ownKeys() {
172
+ return Reflect.ownKeys(currentValue)
173
+ },
174
+ })
175
+
176
+ const read = () => proxy
177
+
178
+ const write = (newValue: T) => {
179
+ const next = unwrap(newValue)
180
+ const prev = currentValue
181
+ currentValue = next
182
+
183
+ if (prev === next) {
184
+ // Same ref update: re-evaluate all tracked signals
185
+ // This is necessary for in-place mutations
186
+ for (const [prop, s] of signals) {
187
+ const newVal = (next as any)[prop]
188
+ s(newVal)
189
+ }
190
+ return
191
+ }
192
+
193
+ // Diff logic
194
+ // We only trigger signals for properties that exist in our cache (tracked)
195
+ // and have changed.
196
+ for (const [prop, s] of signals) {
197
+ const oldVal = (prev as any)[prop]
198
+ const newVal = (next as any)[prop]
199
+ if (oldVal !== newVal) {
200
+ s(newVal)
201
+ }
202
+ }
203
+
204
+ // Note: If new properties appeared that weren't tracked, we don't care
205
+ // because no one is listening.
206
+ // If we assume shape stability (Keyed List), this is efficient.
207
+ }
208
+
209
+ return [read, write] as const
210
+ }
@@ -0,0 +1,187 @@
1
+ import { createElement } from './dom'
2
+ import { createEffect } from './effect'
3
+ import {
4
+ createRootContext,
5
+ destroyRoot,
6
+ flushOnMount,
7
+ getCurrentRoot,
8
+ handleError,
9
+ pushRoot,
10
+ popRoot,
11
+ registerSuspenseHandler,
12
+ } from './lifecycle'
13
+ import { insertNodesBefore, removeNodes, toNodeArray } from './node-ops'
14
+ import { createSignal } from './signal'
15
+ import type { BaseProps, FictNode, SuspenseToken } from './types'
16
+
17
+ export interface SuspenseProps extends BaseProps {
18
+ fallback: FictNode | ((err?: unknown) => FictNode)
19
+ onResolve?: () => void
20
+ onReject?: (err: unknown) => void
21
+ resetKeys?: unknown | (() => unknown)
22
+ }
23
+
24
+ export interface SuspenseHandle {
25
+ token: SuspenseToken
26
+ resolve: () => void
27
+ reject: (err: unknown) => void
28
+ }
29
+
30
+ export function createSuspenseToken(): SuspenseHandle {
31
+ let resolve!: () => void
32
+ let reject!: (err: unknown) => void
33
+ const promise = new Promise<void>((res, rej) => {
34
+ resolve = res
35
+ reject = rej
36
+ })
37
+ return {
38
+ token: {
39
+ then: promise.then.bind(promise),
40
+ },
41
+ resolve,
42
+ reject,
43
+ }
44
+ }
45
+
46
+ const isThenable = (value: unknown): value is PromiseLike<unknown> =>
47
+ typeof value === 'object' &&
48
+ value !== null &&
49
+ typeof (value as PromiseLike<unknown>).then === 'function'
50
+
51
+ export function Suspense(props: SuspenseProps): FictNode {
52
+ const currentView = createSignal<FictNode | null>(props.children ?? null)
53
+ const pending = createSignal(0)
54
+ let resolvedOnce = false
55
+ let epoch = 0
56
+ const hostRoot = getCurrentRoot()
57
+
58
+ const toFallback = (err?: unknown) =>
59
+ typeof props.fallback === 'function'
60
+ ? (props.fallback as (e?: unknown) => FictNode)(err)
61
+ : props.fallback
62
+
63
+ const switchView = (view: FictNode | null) => {
64
+ currentView(view)
65
+ renderView(view)
66
+ }
67
+
68
+ const renderView = (view: FictNode | null) => {
69
+ if (cleanup) {
70
+ cleanup()
71
+ cleanup = undefined
72
+ }
73
+ if (activeNodes.length) {
74
+ removeNodes(activeNodes)
75
+ activeNodes = []
76
+ }
77
+
78
+ if (view == null || view === false) {
79
+ return
80
+ }
81
+
82
+ const root = createRootContext(hostRoot)
83
+ const prev = pushRoot(root)
84
+ let nodes: Node[] = []
85
+ try {
86
+ const output = createElement(view)
87
+ nodes = toNodeArray(output)
88
+ // Suspended view: child threw a suspense token and was handled upstream.
89
+ // Avoid replacing existing fallback content; tear down this attempt.
90
+ const suspendedAttempt =
91
+ nodes.length > 0 &&
92
+ nodes.every(node => node instanceof Comment && (node as Comment).data === 'fict:suspend')
93
+ if (suspendedAttempt) {
94
+ popRoot(prev)
95
+ destroyRoot(root)
96
+ return
97
+ }
98
+ const parentNode = marker.parentNode as (ParentNode & Node) | null
99
+ if (parentNode) {
100
+ insertNodesBefore(parentNode, nodes, marker)
101
+ }
102
+ } catch (err) {
103
+ popRoot(prev)
104
+ flushOnMount(root)
105
+ destroyRoot(root)
106
+ handleError(err, { source: 'render' })
107
+ return
108
+ }
109
+ popRoot(prev)
110
+ flushOnMount(root)
111
+
112
+ cleanup = () => {
113
+ destroyRoot(root)
114
+ removeNodes(nodes)
115
+ }
116
+ activeNodes = nodes
117
+ }
118
+
119
+ const fragment = document.createDocumentFragment()
120
+ const marker = document.createComment('fict:suspense')
121
+ fragment.appendChild(marker)
122
+ let cleanup: (() => void) | undefined
123
+ let activeNodes: Node[] = []
124
+
125
+ const onResolveMaybe = () => {
126
+ if (!resolvedOnce) {
127
+ resolvedOnce = true
128
+ props.onResolve?.()
129
+ }
130
+ }
131
+
132
+ registerSuspenseHandler(token => {
133
+ const tokenEpoch = epoch
134
+ pending(pending() + 1)
135
+ switchView(toFallback())
136
+
137
+ const thenable = (token as SuspenseToken).then
138
+ ? (token as SuspenseToken)
139
+ : isThenable(token)
140
+ ? token
141
+ : null
142
+
143
+ if (thenable) {
144
+ thenable.then(
145
+ () => {
146
+ if (epoch !== tokenEpoch) return
147
+ pending(Math.max(0, pending() - 1))
148
+ if (pending() === 0) {
149
+ switchView(props.children ?? null)
150
+ onResolveMaybe()
151
+ }
152
+ },
153
+ err => {
154
+ if (epoch !== tokenEpoch) return
155
+ pending(Math.max(0, pending() - 1))
156
+ props.onReject?.(err)
157
+ handleError(err, { source: 'render' }, hostRoot)
158
+ },
159
+ )
160
+ return true
161
+ }
162
+
163
+ return false
164
+ })
165
+
166
+ createEffect(() => {
167
+ renderView(currentView())
168
+ })
169
+
170
+ if (props.resetKeys !== undefined) {
171
+ const isGetter =
172
+ typeof props.resetKeys === 'function' && (props.resetKeys as () => unknown).length === 0
173
+ const getter = isGetter ? (props.resetKeys as () => unknown) : undefined
174
+ let prev = isGetter ? getter!() : props.resetKeys
175
+ createEffect(() => {
176
+ const next = getter ? getter() : props.resetKeys
177
+ if (prev !== next) {
178
+ prev = next
179
+ epoch++
180
+ pending(0)
181
+ switchView(props.children ?? null)
182
+ }
183
+ })
184
+ }
185
+
186
+ return fragment
187
+ }
@@ -0,0 +1,128 @@
1
+ import { createEffect } from './effect'
2
+ import { setTransitionContext, signal, scheduleFlush, untrack } from './signal'
3
+
4
+ // ============================================================================
5
+ // startTransition - Mark updates as low priority
6
+ // ============================================================================
7
+
8
+ /**
9
+ * Execute a function with low-priority scheduling.
10
+ * Updates triggered inside the callback will be processed after any high-priority updates.
11
+ * This keeps the UI responsive during expensive operations.
12
+ *
13
+ * @param fn - The function to execute in transition context
14
+ *
15
+ * @example
16
+ * ```tsx
17
+ * const handleInput = (e) => {
18
+ * query = e.target.value // High priority: immediate
19
+ * startTransition(() => {
20
+ * // Low priority: processed after high priority updates
21
+ * filteredItems = allItems.filter(x => x.includes(query))
22
+ * })
23
+ * }
24
+ * ```
25
+ */
26
+ export function startTransition(fn: () => void): void {
27
+ const prev = setTransitionContext(true)
28
+ try {
29
+ fn()
30
+ } finally {
31
+ setTransitionContext(prev)
32
+ scheduleFlush()
33
+ }
34
+ }
35
+
36
+ // ============================================================================
37
+ // useTransition - Hook for managing transition state
38
+ // ============================================================================
39
+
40
+ /**
41
+ * React-style useTransition hook.
42
+ * Returns a pending signal and a startTransition function.
43
+ *
44
+ * @returns A tuple of [isPending accessor, startTransition function]
45
+ *
46
+ * @example
47
+ * ```tsx
48
+ * function SearchComponent() {
49
+ * let query = $state('')
50
+ * const [isPending, start] = useTransition()
51
+ *
52
+ * const handleChange = (e) => {
53
+ * query = e.target.value
54
+ * start(() => {
55
+ * // Expensive filtering happens in low priority
56
+ * filteredResults = expensiveFilter(allData, query)
57
+ * })
58
+ * }
59
+ *
60
+ * return (
61
+ * <>
62
+ * <input value={query} onInput={handleChange} />
63
+ * {isPending() && <Spinner />}
64
+ * <Results items={filteredResults} />
65
+ * </>
66
+ * )
67
+ * }
68
+ * ```
69
+ */
70
+ export function useTransition(): [() => boolean, (fn: () => void) => void] {
71
+ const pending = signal(false)
72
+
73
+ const start = (fn: () => void) => {
74
+ pending(true)
75
+ startTransition(() => {
76
+ try {
77
+ fn()
78
+ } finally {
79
+ pending(false)
80
+ }
81
+ })
82
+ }
83
+
84
+ return [() => pending(), start]
85
+ }
86
+
87
+ // ============================================================================
88
+ // useDeferredValue - Defer value updates to low priority
89
+ // ============================================================================
90
+
91
+ /**
92
+ * Creates a deferred version of a value that updates with low priority.
93
+ * The returned accessor will lag behind the source value during rapid updates,
94
+ * allowing high-priority work to complete first.
95
+ *
96
+ * @param getValue - Accessor function that returns the source value
97
+ * @returns Accessor function that returns the deferred value
98
+ *
99
+ * @example
100
+ * ```tsx
101
+ * function SearchResults({ query }) {
102
+ * const deferredQuery = useDeferredValue(() => query)
103
+ *
104
+ * // deferredQuery lags behind query during rapid typing
105
+ * const results = expensiveSearch(deferredQuery())
106
+ *
107
+ * return <ResultList items={results} />
108
+ * }
109
+ * ```
110
+ */
111
+ export function useDeferredValue<T>(getValue: () => T): () => T {
112
+ const deferredValue = signal(getValue())
113
+
114
+ // Track source value changes and update deferred value in transition
115
+ createEffect(() => {
116
+ const newValue = getValue()
117
+ // Use untrack to read current deferred value without creating a dependency
118
+ // This prevents the effect from re-running when deferredValue changes
119
+ const currentDeferred = untrack(() => deferredValue())
120
+ if (currentDeferred !== newValue) {
121
+ startTransition(() => {
122
+ deferredValue(newValue)
123
+ })
124
+ }
125
+ })
126
+
127
+ return () => deferredValue()
128
+ }