@preact-mvc/atoll-island 0.0.0-stage → 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/package.json CHANGED
@@ -1,6 +1,39 @@
1
1
  {
2
2
  "name": "@preact-mvc/atoll-island",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "preact-mvc island adapter: <Island/> shell components + Preact renderer for @preact-mvc/atoll worker islands",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/index.ts",
8
+ "./worker": "./src/worker.ts",
9
+ "./package.json": "./package.json",
10
+ "./*": "./src/*"
11
+ },
12
+ "files": [
13
+ "src",
14
+ "!src/**/*.test.ts",
15
+ "!src/**/*.test.tsx"
16
+ ],
17
+ "scripts": {
18
+ "typecheck": "tsc --noEmit"
19
+ },
20
+ "dependencies": {
21
+ "@preact-mvc/atoll": "0.1.0",
22
+ "@preact-mvc/core": "0.1.0",
23
+ "preact": "^10.24.0"
24
+ },
25
+ "devDependencies": {
26
+ "@atolljs/core": "0.1.11",
27
+ "@atolljs/islands": "0.1.11"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public",
31
+ "registry": "https://registry.npmjs.org"
32
+ },
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "https://github.com/jwhenry3/preact-mvc",
36
+ "directory": "packages/atoll-island"
37
+ },
38
+ "license": "MIT"
39
+ }
@@ -0,0 +1,258 @@
1
+ /**
2
+ * The framework renderer adapter — our `*-island` package's renderer half
3
+ * (docs/atoll/islands-frameworks.md). `viewIslandApp(Component)` wraps a
4
+ * `View` class or function component as a `RenderedIslandApp`; each mount
5
+ * renders into the instance's proxy document via Preact, so every DOM
6
+ * mutation serializes to ops.
7
+ *
8
+ * `defineViewPolyWorker`/`defineViewMonoWorker` are the single-framework
9
+ * convenience definers — they wrap plain components automatically:
10
+ *
11
+ * // render.worker.ts
12
+ * export const worker = defineViewPolyWorker({
13
+ * apps: { counter: CounterView, clock: ClockView },
14
+ * });
15
+ *
16
+ * DI inside islands: the app gets a fresh root injector per instance —
17
+ * worker-side injectables (stores, controllers) live in the worker, and
18
+ * `@Component({ providers/imports/controllers })` metadata works unchanged
19
+ * because the View host machinery runs in the worker too.
20
+ */
21
+ import { h, options, render } from 'preact';
22
+ import type { ComponentType, VNode } from 'preact';
23
+ import { Injector, InjectorContext, type Provider } from '@preact-mvc/core';
24
+ import {
25
+ defineMonoWorker,
26
+ definePolyWorker,
27
+ islandApp,
28
+ islandAppNameOf,
29
+ enterInstance,
30
+ exitInstance,
31
+ getActiveInstance,
32
+ withContract,
33
+ ProxyElement,
34
+ type ImperativeIslandApp,
35
+ type IslandContract,
36
+ type IslandWorkerDef,
37
+ type RenderContext,
38
+ type RenderedHandle,
39
+ type RenderedIslandApp,
40
+ } from '@preact-mvc/atoll/worker';
41
+
42
+ type AnyComponent = ComponentType<Record<string, unknown>>;
43
+
44
+ const ISLAND_KEY = '__islandInstance';
45
+ const PREV_KEY = '__islandPrevScope';
46
+
47
+ /**
48
+ * Instance-scoped rendering for OUT-OF-TASK renders. Mount/updateProps run
49
+ * inside `runInInstance`, but signal-driven re-renders are deferred by
50
+ * Preact to a microtask with no active scope — ambient `document` would
51
+ * resolve via the last-active heuristic and could create nodes on a SIBLING
52
+ * island's document (in shared-client/in-process topologies).
53
+ *
54
+ * Fix: every root vnode is stamped `__islandInstance`; `options.__r` walks
55
+ * the `__` parent chain before each component render and enters that
56
+ * instance's scope; `options.diffed` restores. Deterministic, and strictly
57
+ * better than the ambient heuristic.
58
+ */
59
+ let renderScopeInstalled = false;
60
+ function installRenderScope(): void {
61
+ if (renderScopeInstalled) return;
62
+ renderScopeInstalled = true;
63
+ type IVNode = VNode & { [ISLAND_KEY]?: string; [PREV_KEY]?: string | null; __?: IVNode | null };
64
+ const opts = options as unknown as {
65
+ __r?: (v: VNode) => void;
66
+ diffed?: (v: VNode) => void;
67
+ };
68
+ const prevR = opts.__r;
69
+ const prevD = opts.diffed;
70
+ opts.__r = (vnode: VNode) => {
71
+ let v = vnode as IVNode | null;
72
+ let key: string | undefined;
73
+ while (v) {
74
+ if (v[ISLAND_KEY]) {
75
+ key = v[ISLAND_KEY];
76
+ break;
77
+ }
78
+ v = v.__ ?? null;
79
+ }
80
+ if (key && key !== getActiveInstance())
81
+ (vnode as IVNode)[PREV_KEY] = enterInstance(key);
82
+ prevR?.(vnode);
83
+ };
84
+ opts.diffed = (vnode: VNode) => {
85
+ prevD?.(vnode);
86
+ const v = vnode as IVNode;
87
+ if (PREV_KEY in v) {
88
+ exitInstance(v[PREV_KEY] ?? null);
89
+ delete v[PREV_KEY];
90
+ }
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Event-name bridge for the proxy DOM. Preact decides the DOM event name
96
+ * via `'onclick' in el`: real elements report the lowercase handler props
97
+ * as present, so `onClick` registers `addEventListener('click', …)` and
98
+ * keys `dom._listeners['click']`. The proxy element has no `on*` props —
99
+ * the probe fails and Preact uses the prop-sliced name 'Click' for BOTH.
100
+ * The driver then listens for a type real DOM never dispatches (no events
101
+ * arrive), and `eventProxy` looks its dict up under 'Click' anyway.
102
+ *
103
+ * The bridge normalizes at the proxy boundary: the `listen` op carries
104
+ * the real DOM type ('click' — driver attaches a working listener) while
105
+ * the wrapped handler re-tags the enriched payload with the prop-sliced
106
+ * name ('Click') so Preact's `eventProxy` still finds its dict entry.
107
+ * All-caps ('DOMContentLoaded') and lowercase types pass through. The
108
+ * wrapped-handler map keeps removeEventListener able to detach the same
109
+ * registration.
110
+ */
111
+ type EventFn = (this: unknown, e: unknown) => void;
112
+ let eventBridgeInstalled = false;
113
+ function installEventNameBridge(): void {
114
+ if (eventBridgeInstalled) return;
115
+ eventBridgeInstalled = true;
116
+ /** element → original fn → prop-sliced type → wrapped fn */
117
+ const bridged = new WeakMap<object, Map<EventFn, Map<string, EventFn>>>();
118
+
119
+ // Prop-sliced PascalCase → the real lowercase DOM type. Only the
120
+ // PascalCase shape is rewritten: 'MouseLeave' → 'mouseleave',
121
+ // 'DblClick' → 'dblclick'. All-caps ('DOMContentLoaded'), lowercase
122
+ // ('click'), and camelCase custom types pass through — lowercasing
123
+ // 'DOMContentLoaded' would break it the other way.
124
+ const decap = (type: string): string =>
125
+ type.length > 1 &&
126
+ type[0] === type[0]!.toUpperCase() &&
127
+ type[1] === type[1]!.toLowerCase()
128
+ ? type.toLowerCase()
129
+ : type;
130
+
131
+ type ListenerMethods = {
132
+ addEventListener: (this: object, type: string, fn: EventFn, opts?: unknown) => void;
133
+ removeEventListener: (this: object, type: string, fn: EventFn, opts?: unknown) => void;
134
+ };
135
+ const proto = ProxyElement.prototype as unknown as ListenerMethods;
136
+ const origAdd = proto.addEventListener;
137
+ const origRemove = proto.removeEventListener;
138
+
139
+ proto.addEventListener = function (
140
+ this: object,
141
+ type: string,
142
+ fn: EventFn,
143
+ opts?: unknown,
144
+ ): void {
145
+ const dom = decap(type);
146
+ let effective = fn;
147
+ if (dom !== type) {
148
+ effective = function (this: unknown, e: unknown): void {
149
+ fn.call(this, { ...(e as Record<string, unknown>), type });
150
+ };
151
+ let byFn = bridged.get(this);
152
+ if (byFn === undefined) bridged.set(this, (byFn = new Map()));
153
+ let byType = byFn.get(fn);
154
+ if (byType === undefined) byFn.set(fn, (byType = new Map()));
155
+ byType.set(type, effective);
156
+ }
157
+ origAdd.call(this, dom, effective, opts);
158
+ };
159
+
160
+ proto.removeEventListener = function (
161
+ this: object,
162
+ type: string,
163
+ fn: EventFn,
164
+ opts?: unknown,
165
+ ): void {
166
+ const effective = bridged.get(this)?.get(fn)?.get(type) ?? fn;
167
+ origRemove.call(this, decap(type), effective, opts);
168
+ };
169
+ }
170
+
171
+ export interface ViewIslandAppOptions {
172
+ /** Providers for the island's root injector — the island-side equivalent
173
+ * of `bootstrap(AppModule, providers)`. */
174
+ providers?: Provider[];
175
+ /** Attach a `defineIslandContract` contract: mount/updateProps props are
176
+ * validated against its schema, and declared `emit` payloads are
177
+ * checked worker-side. */
178
+ contract?: IslandContract;
179
+ }
180
+
181
+ /** Wrap a View class or function component as a `RenderedIslandApp`. */
182
+ export function viewIslandApp(App: AnyComponent, opts?: ViewIslandAppOptions): RenderedIslandApp {
183
+ installRenderScope();
184
+ installEventNameBridge();
185
+ const app: RenderedIslandApp = {
186
+ renderer: 'preact-mvc',
187
+ mount({ instance, doc, props }: RenderContext): RenderedHandle {
188
+ const inj = new Injector(undefined, `island ${instance}`);
189
+ for (const p of opts?.providers ?? []) inj.provide(p);
190
+ let current = props;
191
+ const renderRoot = (p: Record<string, unknown>): void => {
192
+ current = p;
193
+ const vnode = h(InjectorContext.Provider, { value: inj }, h(App, p));
194
+ (vnode as unknown as Record<string, unknown>)[ISLAND_KEY] = instance;
195
+ render(vnode, doc.body as never);
196
+ };
197
+ renderRoot(props);
198
+ return {
199
+ update: renderRoot,
200
+ // The dispatch task's sync lane: run the event handler, then
201
+ // re-render synchronously (Preact's render() IS the flush — signal
202
+ // writes would otherwise defer to a microtask and their ops would
203
+ // miss this task's batch, surfacing a poll later).
204
+ sync: (fn: () => void) => {
205
+ fn();
206
+ renderRoot(current);
207
+ },
208
+ dispose: () => {
209
+ try {
210
+ render(null, doc.body as never);
211
+ } finally {
212
+ inj.deactivate();
213
+ }
214
+ },
215
+ };
216
+ },
217
+ };
218
+ const stamp = islandAppNameOf(App);
219
+ if (stamp !== undefined) (app as { islandAppName?: string }).islandAppName = stamp;
220
+ return opts?.contract !== undefined ? withContract(opts.contract, app) : app;
221
+ }
222
+
223
+ /** Stamp + wrap in one step — `viewIsland('counter', CounterView)` yields
224
+ * both the registry value and the name the shell mounts by. */
225
+ export const viewIsland = (
226
+ name: string,
227
+ App: AnyComponent,
228
+ opts?: ViewIslandAppOptions,
229
+ ): RenderedIslandApp & { readonly islandAppName: string } =>
230
+ islandApp(name, viewIslandApp(App, opts));
231
+
232
+ export interface ViewPolyWorkerRegistry {
233
+ /** Name → component (or `{ imperative }` app / pre-wrapped app). Plain
234
+ * components pass through `viewIslandApp`. */
235
+ apps: Record<string, AnyComponent | ImperativeIslandApp | RenderedIslandApp>;
236
+ }
237
+
238
+ /** `definePolyWorker` for framework views — maps each component in the
239
+ * registry through `viewIslandApp` and delegates. */
240
+ export function defineViewPolyWorker(registry: ViewPolyWorkerRegistry): IslandWorkerDef {
241
+ installRenderScope();
242
+ const apps: Record<string, ImperativeIslandApp | RenderedIslandApp> = {};
243
+ for (const [key, app] of Object.entries(registry.apps)) {
244
+ apps[key] = typeof app === 'function' ? viewIslandApp(app) : app;
245
+ }
246
+ return definePolyWorker({ apps });
247
+ }
248
+
249
+ /** `defineMonoWorker` for a single framework view — the isolated-bundle
250
+ * host shape (own worker, own failure domain). */
251
+ export function defineViewMonoWorker(
252
+ app: AnyComponent | ImperativeIslandApp | RenderedIslandApp,
253
+ opts?: ViewIslandAppOptions,
254
+ ): IslandWorkerDef {
255
+ installRenderScope();
256
+ const resolved = typeof app === 'function' ? viewIslandApp(app, opts) : app;
257
+ return defineMonoWorker(resolved);
258
+ }
package/src/index.ts ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * `@preact-mvc/atoll-island` — the `*-island` package's shell surface
3
+ * (docs/atoll/islands-frameworks.md): mount a worker-hosted island tree as
4
+ * an ordinary element in a preact-mvc app. The worker-side renderer
5
+ * (viewIslandApp, defineViewPolyWorker) lives in the `./worker` entry.
6
+ *
7
+ * The facade pattern: a contract module (`defineIslandContract`) names the
8
+ * app, declares the wire schemas, and carries the worker factory — the
9
+ * view imports the contract only and mounts `islandComponent(contract)`
10
+ * with no worker plumbing at the call site.
11
+ *
12
+ * The driver/mount API is re-exported from `@preact-mvc/atoll` so shell
13
+ * code only needs this one package.
14
+ */
15
+ export { Island, islandComponent, lazyIsland } from './shell';
16
+ export type {
17
+ IslandAppRef,
18
+ IslandFacadeProps,
19
+ IslandProps,
20
+ IslandShellProps,
21
+ } from './shell';
22
+ export {
23
+ callbackProp,
24
+ connectIslandWorker,
25
+ contractOf,
26
+ contractWorkerOf,
27
+ defineIslandContract,
28
+ isIslandContract,
29
+ islandApp,
30
+ islandAppNameOf,
31
+ mountIsland,
32
+ withContract,
33
+ z,
34
+ } from '@preact-mvc/atoll';
35
+ export type {
36
+ ConnectOptions,
37
+ IslandAppProps,
38
+ IslandClient,
39
+ IslandContract,
40
+ IslandContractEventHandler,
41
+ IslandContractEvents,
42
+ IslandContractProps,
43
+ IslandHandle,
44
+ IslandWorkerOptions,
45
+ Mode,
46
+ MountOptions,
47
+ Schema,
48
+ WorkerLike,
49
+ } from '@preact-mvc/atoll';
package/src/shell.tsx ADDED
@@ -0,0 +1,280 @@
1
+ /**
2
+ * The shell surface — our `*-island` package's main-thread half
3
+ * (docs/atoll/islands-frameworks.md): mount a worker-hosted tree as an
4
+ * ordinary element in a framework app.
5
+ *
6
+ * <Island worker={entry} app="counter" props={{ start: 5 }}
7
+ * onEvent={(name, p) => ...} />
8
+ *
9
+ * const Counter = islandComponent(counterContract); // the facade:
10
+ * <Counter start={5} onEvent={...} /> // no worker prop —
11
+ * the contract carries it
12
+ *
13
+ * const Lazy = lazyIsland(() => import('./counter.contract'));
14
+ *
15
+ * These are plain function components — hooks are legal here (only `View`
16
+ * classes forbid them). `worker`/`client`/`app` are mount identity: change
17
+ * them with a `key`.
18
+ */
19
+ import { h } from 'preact';
20
+ import { useEffect, useRef, useState } from 'preact/hooks';
21
+ import type { ComponentChildren, FunctionComponent } from 'preact';
22
+ import {
23
+ connectIslandWorker,
24
+ contractWorkerOf,
25
+ islandAppNameOf,
26
+ isIslandContract,
27
+ mountIsland,
28
+ } from '@preact-mvc/atoll';
29
+ import type {
30
+ IslandAppProps,
31
+ IslandClient,
32
+ IslandContract,
33
+ IslandContractEventHandler,
34
+ IslandContractProps,
35
+ IslandHandle,
36
+ IslandWorkerOptions,
37
+ Mode,
38
+ WorkerLike,
39
+ } from '@preact-mvc/atoll';
40
+
41
+ /** Props consumed by a proxy component itself — everything else forwards to
42
+ * the island as its props. */
43
+ export interface IslandShellProps {
44
+ /** Bundler-detectable factory `() => new Worker(new URL(...))` (or a URL
45
+ * / spawned worker). Optional when `app` is a contract carrying its own
46
+ * worker — the contract module is then the whole connection. */
47
+ worker?: WorkerLike | (() => WorkerLike) | URL;
48
+ /** A shared `connectIslandWorker` client — this mount adds an instance. */
49
+ client?: IslandClient;
50
+ /** Client options for `worker`-shorthand mounts (taskTimeout, respawn,
51
+ * lazy, doorbell). `doorbell: true` needs cross-origin isolation. */
52
+ workerOptions?: IslandWorkerOptions & { doorbell?: boolean };
53
+ /** 'push' rides the doorbell SAB (COOP/COEP); 'poll' (default) is
54
+ * message-only — no headers needed. */
55
+ mode?: Mode;
56
+ /** Devtools renderer tag. */
57
+ framework?: string;
58
+ /** Receives every worker-side `emit(name, payload)`. */
59
+ onEvent?: (name: string, payload: unknown) => void;
60
+ /** Fired after each applied op batch. */
61
+ onActivity?: () => void;
62
+ /** The mounted handle (pid, updateProps, flush, setMode…) once ready. */
63
+ onReady?: (island: IslandHandle) => void;
64
+ /** Mount/update errors surface here instead of an unhandled rejection. */
65
+ onError?: (err: unknown) => void;
66
+ /** `data-atoll-slot` transclusion holes: worker owns the box, shell owns
67
+ * the contents. */
68
+ slots?: Record<string, (el: HTMLElement | null) => void>;
69
+ /** Rendered while the worker mounts (lazy facades). */
70
+ fallback?: ComponentChildren;
71
+ className?: string;
72
+ style?: string | Record<string, string | number>;
73
+ mountTimeout?: number;
74
+ children?: ComponentChildren; // reserved for future slot sugar
75
+ }
76
+
77
+ export interface IslandProps extends IslandShellProps {
78
+ /** Registry key, stamped app (`islandApp`/`viewIsland`), or contract —
79
+ * resolved through `islandAppNameOf`. */
80
+ app?: unknown;
81
+ /** Structured-cloneable props (functions via `callbackProp`). */
82
+ props?: Record<string, unknown>;
83
+ }
84
+
85
+ export const Island: FunctionComponent<IslandProps> = (p) => {
86
+ const ref = useRef<HTMLDivElement>(null);
87
+ const handle = useRef<IslandHandle | null>(null);
88
+ const [failed, setFailed] = useState<Error | null>(null);
89
+
90
+ // 'main' mounts a mono worker's sole app namelessly; registries error
91
+ // with the key list if 'main' isn't a real key.
92
+ const appName = p.app === undefined ? 'main' : islandAppNameOf(p.app);
93
+
94
+ // Mount once per app — worker/client/app are identity; change via `key`.
95
+ useEffect(() => {
96
+ if (appName === undefined) {
97
+ setFailed(
98
+ new Error(
99
+ '<Island> could not resolve an app name — pass the registry key ' +
100
+ 'or stamp the app with islandApp(name, app)',
101
+ ),
102
+ );
103
+ return;
104
+ }
105
+ const mode = p.mode ?? 'poll';
106
+ // A contract `app` can carry its own worker factory — the contract
107
+ // module is then the whole connection, call sites pass no worker.
108
+ const resolvedWorker = p.worker ?? contractWorkerOf(p.app);
109
+ const client =
110
+ p.client ??
111
+ (resolvedWorker !== undefined
112
+ ? connectIslandWorker({
113
+ name: appName,
114
+ worker: resolvedWorker,
115
+ doorbell: mode !== 'poll',
116
+ ...p.workerOptions,
117
+ })
118
+ : undefined);
119
+ if (client === undefined) {
120
+ setFailed(new Error('<Island> requires either `worker`, `client`, or a contract carrying `worker`'));
121
+ return;
122
+ }
123
+ let cancelled = false;
124
+ mountIsland({
125
+ el: ref.current!,
126
+ client,
127
+ app: appName,
128
+ props: p.props,
129
+ onEvent: p.onEvent,
130
+ onActivity: p.onActivity,
131
+ slots: p.slots,
132
+ mode,
133
+ framework: p.framework,
134
+ mountTimeout: p.mountTimeout,
135
+ })
136
+ .then((h) => {
137
+ if (cancelled) h.destroy();
138
+ else {
139
+ handle.current = h;
140
+ p.onReady?.(h);
141
+ }
142
+ })
143
+ .catch((err) => {
144
+ if (cancelled) return;
145
+ if (p.onError) p.onError(err);
146
+ else setFailed(err instanceof Error ? err : new Error(String(err)));
147
+ });
148
+ return () => {
149
+ cancelled = true;
150
+ handle.current?.destroy();
151
+ handle.current = null;
152
+ };
153
+ // eslint-disable-next-line react-hooks/exhaustive-deps
154
+ }, [appName, p.client]);
155
+
156
+ // Prop updates flow through the handle — deduped by wire-serialized
157
+ // identity; a repeat send would be a wasted round-trip.
158
+ const propsJson = JSON.stringify(p.props ?? {});
159
+ useEffect(() => {
160
+ handle.current?.updateProps(p.props ?? {});
161
+ // eslint-disable-next-line react-hooks/exhaustive-deps
162
+ }, [propsJson]);
163
+
164
+ if (failed) {
165
+ return (
166
+ <div class="island-error" style={{ border: '1px solid #c00', padding: '0.5em' }}>
167
+ Island mount failed: {failed.message}
168
+ </div>
169
+ );
170
+ }
171
+ return <div ref={ref} class={p.className} style={p.style as string} />;
172
+ };
173
+
174
+ /** What `app` accepts on a facade: the registry name, or a reference to
175
+ * resolve (component, stamped app, contract). */
176
+ export type IslandAppRef<A> = A | string;
177
+
178
+ export type IslandFacadeProps<A> = Omit<IslandShellProps, 'onEvent'> &
179
+ IslandAppProps<A> & {
180
+ props?: Record<string, unknown>;
181
+ onEvent?: (name: string, payload: unknown) => void;
182
+ };
183
+
184
+ /** Props consumed by a facade component itself — everything else forwards
185
+ * to the island as its props. */
186
+ const SHELL_PROP_KEYS: ReadonlySet<string> = new Set([
187
+ 'worker',
188
+ 'client',
189
+ 'workerOptions',
190
+ 'mode',
191
+ 'framework',
192
+ 'onEvent',
193
+ 'onActivity',
194
+ 'onReady',
195
+ 'onError',
196
+ 'slots',
197
+ 'fallback',
198
+ 'className',
199
+ 'style',
200
+ 'mountTimeout',
201
+ 'children',
202
+ ]);
203
+
204
+ const splitProxyProps = (
205
+ raw: Record<string, unknown>,
206
+ ): { shell: IslandShellProps; islandProps: Record<string, unknown> } => {
207
+ const shell: Record<string, unknown> = {};
208
+ const islandProps: Record<string, unknown> = {};
209
+ for (const [key, value] of Object.entries(raw)) {
210
+ if (SHELL_PROP_KEYS.has(key)) shell[key] = value;
211
+ else islandProps[key] = value;
212
+ }
213
+ return { shell: shell as IslandShellProps, islandProps };
214
+ };
215
+
216
+ /**
217
+ * `islandComponent(contract)` — the cross-framework facade: the component's
218
+ * props are the island's props (from the contract's schema), `onEvent`
219
+ * narrows to the declared event vocabulary, and the contract's own worker
220
+ * factory means call sites pass NO worker/client at all. The shell imports
221
+ * the contract module only — never the worker entry or the island's code.
222
+ *
223
+ * `islandComponent<P>('charts')` — the string-keyed form for registries
224
+ * whose workers the shell already knows.
225
+ */
226
+ export function islandComponent<C extends IslandContract>(
227
+ app: C,
228
+ ): FunctionComponent<
229
+ IslandContractProps<C> &
230
+ Omit<IslandShellProps, 'onEvent'> & {
231
+ props?: Record<string, unknown>;
232
+ onEvent?: IslandContractEventHandler<C>;
233
+ }
234
+ >;
235
+ export function islandComponent<A>(app: A | string): FunctionComponent<IslandFacadeProps<A>>;
236
+ export function islandComponent(app: unknown): FunctionComponent<Record<string, unknown>> {
237
+ const Facade: FunctionComponent<Record<string, unknown>> = ({ props, ...raw }) => {
238
+ const { shell, islandProps } = splitProxyProps(raw as Record<string, unknown>);
239
+ return <Island app={app} props={{ ...islandProps, ...(props ?? {}) }} {...shell} />;
240
+ };
241
+ Facade.displayName = `Island(${islandAppNameOf(app) ?? 'main'})`;
242
+ return Facade as FunctionComponent<Record<string, unknown>>;
243
+ }
244
+
245
+ /**
246
+ * `lazyIsland(() => import('./counter.contract'))` — the module must export
247
+ * a contract (`defineIslandContract` — app + worker factory in one object),
248
+ * a stamped app (`viewIsland('counter', CounterView)`), or a default export
249
+ * resolvable by `islandAppNameOf`. A contract's own worker factory is used
250
+ * when no `worker` prop is passed.
251
+ */
252
+ export function lazyIsland<A>(
253
+ load: () => Promise<Record<string, unknown>>,
254
+ ): FunctionComponent<IslandFacadeProps<A>> {
255
+ const Lazy: FunctionComponent<IslandFacadeProps<A>> = (p) => {
256
+ const [app, setApp] = useState<unknown>(null);
257
+ useEffect(() => {
258
+ let live = true;
259
+ void load().then((mod) => {
260
+ if (!live) return;
261
+ const unwrapped = 'default' in mod ? mod.default : mod;
262
+ const resolved =
263
+ isIslandContract(unwrapped) ? unwrapped
264
+ : Object.values(mod).find((v) => isIslandContract(v)) ??
265
+ Object.values(mod).find(
266
+ (v) => v && typeof v === 'object' && 'islandAppName' in v,
267
+ ) ??
268
+ unwrapped;
269
+ setApp(() => resolved);
270
+ });
271
+ return () => {
272
+ live = false;
273
+ };
274
+ }, []);
275
+ if (!app)
276
+ return <div class={p.className} style={p.style as string}>{p.fallback}</div>;
277
+ return <Island {...(p as IslandProps)} app={app} />;
278
+ };
279
+ return Lazy;
280
+ }
package/src/worker.ts ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * `@preact-mvc/atoll-island/worker` — the `*-island` package's worker
3
+ * renderer (docs/atoll/islands-frameworks.md): render View classes and
4
+ * function components into the instance's proxy document via Preact.
5
+ *
6
+ * // render.worker.ts
7
+ * export const worker = defineViewPolyWorker({
8
+ * apps: { counter: CounterView, clock: ClockView },
9
+ * });
10
+ *
11
+ * The framework-free worker API is re-exported from
12
+ * `@preact-mvc/atoll/worker` so a worker entry needs no direct atoll-core
13
+ * import (same convention as Atoll's `*-island` packages).
14
+ */
15
+ export {
16
+ viewIslandApp,
17
+ viewIsland,
18
+ defineViewPolyWorker,
19
+ defineViewMonoWorker,
20
+ } from './adapter';
21
+ export type { ViewIslandAppOptions, ViewPolyWorkerRegistry } from './adapter';
22
+ /**
23
+ * The mount components on the worker side — same surface as the shell
24
+ * entry (Atoll's `react-island/worker` convention): inside a worker-
25
+ * rendered tree the component's div is a ProxyElement, `mountIsland`
26
+ * takes the nested path, and the sub-island's ops tunnel up the parent
27
+ * instance's op stream. `connectIslandWorker` builds a shared sub-worker
28
+ * client; `emit` in a nested `onEvent` relays events to the page.
29
+ */
30
+ export { Island, islandComponent, lazyIsland } from './shell';
31
+ export type {
32
+ IslandProps,
33
+ IslandShellProps,
34
+ IslandAppRef,
35
+ IslandFacadeProps,
36
+ } from './shell';
37
+ export { connectIslandWorker, mountIsland, callbackProp } from '@preact-mvc/atoll/worker';
38
+ export {
39
+ contractOf,
40
+ defineIslandContract,
41
+ definePolyWorker,
42
+ defineMonoWorker,
43
+ emit,
44
+ installDomShim,
45
+ isIslandContract,
46
+ runInInstance,
47
+ enterInstance,
48
+ exitInstance,
49
+ getActiveInstance,
50
+ getLastActiveInstance,
51
+ islandApp,
52
+ islandAppNameOf,
53
+ withContract,
54
+ ProxyElement,
55
+ } from '@preact-mvc/atoll/worker';
56
+ export type {
57
+ EventPayload,
58
+ ImperativeIslandApp,
59
+ IslandContract,
60
+ RenderedIslandApp,
61
+ RenderContext,
62
+ RenderedHandle,
63
+ IslandApp,
64
+ IslandWorkerDef,
65
+ ProxyDocument,
66
+ } from '@preact-mvc/atoll/worker';
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.