@atolljs/islands 0.1.0

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.
package/src/memory.ts ADDED
@@ -0,0 +1,30 @@
1
+ import { defineSharedMemory, field } from '@atolljs/core';
2
+
3
+ /**
4
+ * The doorbell layout. One counter the worker bumps after every commit —
5
+ * the main thread observe()s it (Atomics.waitAsync underneath) and flushes
6
+ * the op queue on each tick. Ops themselves still ride postMessage; shared
7
+ * memory only replaces the flush() poll with a push.
8
+ */
9
+ const doorbellSpec = {
10
+ opsVersion: field.number(),
11
+ };
12
+
13
+ /** The spec `renderMemory`/`makeDoorbell` share — pinned to the doorbell layout. */
14
+ export type DoorbellSpec = typeof doorbellSpec;
15
+
16
+ /**
17
+ * The worker-side contract. A worker script has ONE module-level instance —
18
+ * the pool's INIT_MEMORY handshake binds it to that worker's own buffer, so
19
+ * every island's worker gets an independent doorbell for free.
20
+ */
21
+ export const renderMemory = defineSharedMemory(doorbellSpec);
22
+
23
+ /**
24
+ * The main-thread side needs one contract INSTANCE per island: a contract
25
+ * object can only be bound to one buffer at a time (bind() rewires its
26
+ * connectors), and each island client creates its own pool with its own
27
+ * SharedArrayBuffer. Same spec, separate instance → separate buffer → a
28
+ * doorbell that only rings for that island's worker.
29
+ */
30
+ export const makeDoorbell = () => defineSharedMemory(doorbellSpec);
package/src/ops.ts ADDED
@@ -0,0 +1,215 @@
1
+ /**
2
+ * The whole protocol between the two threads.
3
+ *
4
+ * The worker runs a real React reconciler whose "host environment" is a set
5
+ * of records in a Map — every render-phase and mutation-phase host-config
6
+ * call appends one of these ops to a queue. Task methods (`mount`,
7
+ * `updateProps`, `dispatch`, `flush`) return the flushed batch, and the main
8
+ * thread's only job is to replay them against the DOM. Ops ride postMessage —
9
+ * no shared memory is involved.
10
+ *
11
+ * ISLANDS: every island owns an independent op stream — instance ids are only
12
+ * unique within one worker, so each island's driver keeps its own node map
13
+ * (see src/island.ts). `emit` is the one op that is NOT a DOM mutation: it is
14
+ * the island→shell event channel — the driver hands `{name, payload}` to the
15
+ * shell-registered `onEvent` callback instead of touching the DOM.
16
+ */
17
+ export type Op =
18
+ /**
19
+ * createElement-equivalent — `props` is already wire-serialized. `ns` is
20
+ * the element's namespace URI when it isn't HTML ('http://www.w3.org/2000/svg',
21
+ * 'http://www.w3.org/1998/Math/MathML') — the driver calls
22
+ * `createElementNS(ns, type)`. The reconciler's host context tracks it
23
+ * (svg/math enter, foreignObject/desc/title return to HTML), so SVG trees
24
+ * like recharts' arrive correctly namespaced.
25
+ */
26
+ | { t: 'create'; id: number; type: string; props: WireProps; ns?: string }
27
+ /** createTextNode-equivalent. */
28
+ | { t: 'text'; id: number; text: string }
29
+ /** `parent.insertBefore(child, before ?? null)` — parent 0 is the root container. */
30
+ | { t: 'append'; parent: number; child: number; before?: number }
31
+ /** Detach `child` from wherever it is mounted. */
32
+ | { t: 'remove'; child: number }
33
+ /** Re-serialized full prop set for `id` (main diffs against its last seen set). */
34
+ | { t: 'update'; id: number; props: WireProps }
35
+ /** New text content for a text instance. Also how the proxy DOM writes
36
+ * `element.textContent` — the driver assigns `node.textContent`, which
37
+ * replaces the element's children with a single text node. */
38
+ | { t: 'utext'; id: number; text: string }
39
+ /** Clear the root container (React's clearContainer, or an imperative
40
+ * instance's rebuild). */
41
+ | { t: 'clear' }
42
+ /**
43
+ * Set/remove a single attribute — emitted by the worker-side proxy DOM
44
+ * (`setAttribute`, `id`/`className` setters, `classList`, `dataset`).
45
+ * `value: null` removes the attribute.
46
+ */
47
+ | { t: 'attr'; id: number; name: string; value: string | null }
48
+ /**
49
+ * Merge inline-style changes — the proxy DOM's `style` proxy re-sends only
50
+ * the changed keys; `''` clears a key (deleteProperty). Two conventions:
51
+ * keys starting `--` are CSS custom properties (driver routes them to
52
+ * `style.setProperty`), and a value ending in `!important` applies with
53
+ * priority (the proxy's `style.setProperty(k, v, 'important')` encodes it
54
+ * this way — there is no separate priority field).
55
+ */
56
+ | { t: 'style'; id: number; props: Record<string, string> }
57
+ /**
58
+ * Attach a DOM listener for a proxy-DOM `addEventListener` — `handler` is
59
+ * a worker handler-table id (same currency as `__evt` refs); the driver
60
+ * wires it to `client.dispatch(handler, payload)`. `id: 0` targets the
61
+ * island's root container — that's where `document`/`window` listeners
62
+ * land, which makes delegated handlers work. `opts` carries the
63
+ * once/passive/capture flags through to the real addEventListener.
64
+ */
65
+ | {
66
+ t: 'listen';
67
+ id: number;
68
+ type: string;
69
+ handler: number;
70
+ opts?: { once?: boolean; passive?: boolean; capture?: boolean };
71
+ }
72
+ /** Detach the listener a `listen` op attached (type + handler identify it).
73
+ * `opts.capture` must match the listen's flag for the detach to apply. */
74
+ | {
75
+ t: 'unlisten';
76
+ id: number;
77
+ type: string;
78
+ handler: number;
79
+ opts?: { once?: boolean; passive?: boolean; capture?: boolean };
80
+ }
81
+ /**
82
+ * Island → shell event — worker code calls `emit(name, payload)` inside a
83
+ * handler (or a commit-phase effect); the driver invokes the island's
84
+ * `onEvent(name, payload)` instead of mutating the DOM. Payloads must be
85
+ * structured-cloneable — same rule as props.
86
+ */
87
+ | { t: 'emit'; name: string; payload?: unknown };
88
+
89
+ /**
90
+ * Wire-serialized props. Functions named `on*` (onClick, onInput, …) cross as
91
+ * `{ __evt: handlerId }`; the main thread turns that into a DOM listener that
92
+ * dispatches back into the worker. `children` never crosses — tree structure
93
+ * is expressed entirely by append/remove ops.
94
+ *
95
+ * One prop is NOT a normal attribute: `data-atoll-slot` marks a transclusion
96
+ * slot — a leaf element whose box is worker-owned but whose contents the
97
+ * shell mounts main-thread DOM into (the island's `slots` registry). The
98
+ * driver routes it to the slot machinery instead of treating it like any
99
+ * other data attribute.
100
+ */
101
+ export type WireProps = Record<string, unknown>;
102
+
103
+ /** Marker for a prop that was a function — a handle into the worker's handler table. */
104
+ export interface EventRef {
105
+ __evt: number;
106
+ }
107
+
108
+ export const isEventRef = (v: unknown): v is EventRef =>
109
+ typeof v === 'object' && v !== null && '__evt' in v;
110
+
111
+ /**
112
+ * What the main thread sends back when a DOM event fires. Deliberately NOT a
113
+ * SyntheticEvent — just a best-effort handful of fields read off the DOM
114
+ * event and its target. Mouse fields are `undefined` for non-mouse events;
115
+ * `targetId` is only set when the event target is an op-created node (shell-
116
+ * owned slot content has no instance id).
117
+ */
118
+ export interface EventPayload {
119
+ type: string;
120
+ value?: string;
121
+ checked?: boolean;
122
+ key?: string;
123
+ clientX?: number;
124
+ clientY?: number;
125
+ screenX?: number;
126
+ screenY?: number;
127
+ button?: number;
128
+ /**
129
+ * UIEvent.which — non-standard but set by every browser (1 = left button,
130
+ * 2 = middle, 3 = right). Libraries like Leaflet's Draggable gate on it:
131
+ * `e.which !== 1 && e.button !== 1` must not both hold for a left press.
132
+ */
133
+ which?: number;
134
+ /** Modifier states for the event (KeyboardEvent/MouseEvent share them). */
135
+ shiftKey?: boolean;
136
+ ctrlKey?: boolean;
137
+ altKey?: boolean;
138
+ metaKey?: boolean;
139
+ /** WheelEvent deltas — only set for `wheel` events (deltaMode is always
140
+ * 0/pixels in practice; reported raw). */
141
+ deltaX?: number;
142
+ deltaY?: number;
143
+ /**
144
+ * WheelEvent.deltaMode (0 = pixels, 1 = lines, 2 = pages). Libraries like
145
+ * Leaflet gate on `e.deltaMode === 0` inside getWheelDelta, so the driver
146
+ * always sends a concrete number — defaulting to 0 when the environment
147
+ * doesn't expose it but deltas exist.
148
+ */
149
+ deltaMode?: number;
150
+ /** PointerEvent.pointerType ('mouse' | 'touch' | 'pen' | ''). */
151
+ pointerType?: string;
152
+ /** `target.scrollTop` when the target is an element. */
153
+ scrollTop?: number;
154
+ /** The worker instance id of `event.target`, when it maps to one. */
155
+ targetId?: number;
156
+ /**
157
+ * The proxy-DOM node for `targetId` — SYNTHESIZED worker-side by the proxy
158
+ * DOM when it wraps a listener (it never crosses the wire). Lets delegated
159
+ * handlers written for the real DOM — `e.target.closest('.row')`,
160
+ * `e.target.dataset` — run unmodified. Only set when the target maps to a
161
+ * known worker instance; DOM-fidelity only for nodes built through the
162
+ * same proxy document.
163
+ */
164
+ target?: unknown;
165
+ /**
166
+ * Synthesized NO-OPS — added worker-side by the proxy DOM when it wraps a
167
+ * listener's payload (they never cross the wire). They exist so library
168
+ * code written for real DOM events — `e.preventDefault()`,
169
+ * `e.stopPropagation()` — doesn't crash. They CANNOT cancel anything: the
170
+ * real event already dispatched on the main thread before this payload
171
+ * ever reached the worker. Practical consequence: a `wheel` listener on a
172
+ * scrolling island can't preventDefault — the page scrolls anyway.
173
+ */
174
+ preventDefault?(): void;
175
+ stopPropagation?(): void;
176
+ stopImmediatePropagation?(): void;
177
+ }
178
+
179
+ /**
180
+ * The task methods every island worker exposes — what `definePolyWorker`
181
+ * registers on the worker side and what `connectIslandWorker`'s typed client
182
+ * calls on the main thread. Every signature leads with the instance key
183
+ * (`'app'` or `'app@instance'`) — `mountIsland` binds it per island.
184
+ */
185
+ export type IslandWorkerMethods = {
186
+ /** Mount the instance's registry app; returns the initial op batch. Async
187
+ * because a React app lazily imports the reconciler on first mount. */
188
+ mount(instance: string, props?: Record<string, unknown>): Op[] | Promise<Op[]>;
189
+ /** Re-render the instance's root with new serializable props. */
190
+ updateProps(instance: string, props: Record<string, unknown>): Op[];
191
+ /** Invoke the worker handler a `__evt` ref or `listen` op points at. */
192
+ dispatch(handlerId: number, payload: EventPayload): Op[];
193
+ /**
194
+ * Push the island container's measured box into the instance — the ONLY
195
+ * geometry channel that exists. The driver observes the island's `el`
196
+ * with a ResizeObserver and calls this once at mount and on resizes
197
+ * (throttled ~100ms). The instance stores {w,h}; proxy-DOM geometry reads on
198
+ * `doc.body`/`documentElement` and on elements marked via
199
+ * `doc.markContainer(el)` then return it — deeper elements keep the
200
+ * honest 0. Handlers registered via `doc.onResize` re-run on each push;
201
+ * their ops ride back in this method's return batch.
202
+ */
203
+ setSize(instance: string, width: number, height: number): Op[];
204
+ /** Drain ops committed outside a task (passive effects, timers). */
205
+ flush(instance: string): Op[];
206
+ /**
207
+ * Tear down one instance — unmounts its tree / disposes its proxy document
208
+ * and drops the instance, leaving the worker alive for its other mounts.
209
+ * Returned ops are the detach batch (the driver may skip applying them if
210
+ * the island element is already gone).
211
+ */
212
+ unmount(instance: string): Op[];
213
+ /** The mounted instance's random id — the island's "worker pid" badge. */
214
+ whoami(instance: string): string;
215
+ };
@@ -0,0 +1,18 @@
1
+ /**
2
+ * react-reconciler ships no TypeScript types. It is a CommonJS factory —
3
+ * `Reconciler(hostConfig)` returns the reconciler API (createContainer,
4
+ * updateContainer, flushSyncWork, flushSyncFromReconciler, flushPassiveEffects…).
5
+ * The host config surface is documented in hostConfig.ts.
6
+ */
7
+ declare module 'react-reconciler' {
8
+ const createReconciler: (hostConfig: unknown) => {
9
+ createContainer: (...args: unknown[]) => unknown;
10
+ updateContainer: (...args: unknown[]) => unknown;
11
+ updateContainerSync: (...args: unknown[]) => unknown;
12
+ flushSyncWork: () => unknown;
13
+ flushSyncFromReconciler: <T>(fn: () => T) => T;
14
+ flushPassiveEffects: () => boolean;
15
+ [key: string]: unknown;
16
+ };
17
+ export default createReconciler;
18
+ }