@preact-mvc/atoll-island 0.1.0 → 0.1.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.
package/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # @preact-mvc/atoll-island
2
+
3
+ [![CI](https://github.com/jwhenry3/preact-mvc/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/jwhenry3/preact-mvc/actions/workflows/ci.yml)
4
+ [![codecov](https://codecov.io/gh/jwhenry3/preact-mvc/graph/badge.svg?branch=main)](https://codecov.io/gh/jwhenry3/preact-mvc)
5
+ [![Socket Badge](https://badge.socket.dev/npm/package/@preact-mvc/atoll-island)](https://badge.socket.dev/npm/package/@preact-mvc/atoll-island)
6
+
7
+ The PreactMVC adapter for `@preact-mvc/atoll` worker islands — the
8
+ `*-island` half of the angular/react vs `*-island` pattern. On the shell,
9
+ `islandComponent(contract)` turns a shared contract into a mountable
10
+ component. In the worker, `viewIslandApp` + `defineViewPolyWorker` render
11
+ `View` classes (or any component) against the proxy DOM, so island views
12
+ get the same DI, modules, lifecycle, and signals as main-thread views.
13
+
14
+ **[preact-mvc — island documentation](https://jwhenry3.github.io/preact-mvc/docs/island-apps/)**
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ npm install @preact-mvc/atoll-island @preact-mvc/core @preact-mvc/atoll
20
+ ```
21
+
22
+ ## Contract → component → island
23
+
24
+ ```ts
25
+ // islands/ticker.contract.ts — imported by BOTH sides
26
+ import { defineIslandContract, z } from '@preact-mvc/atoll-island';
27
+
28
+ export const tickerContract = defineIslandContract({
29
+ app: 'ticker',
30
+ events: { 'worker-ping': z.object({ count: z.number() }) },
31
+ worker: () =>
32
+ new Worker(new URL('../workers/ticker.worker.tsx', import.meta.url), {
33
+ type: 'module',
34
+ }),
35
+ });
36
+ ```
37
+
38
+ ```tsx
39
+ // workers/ticker.worker.tsx — the worker entry
40
+ import { Component, View, signal } from '@preact-mvc/core';
41
+ import { defineViewPolyWorker, viewIslandApp } from '@preact-mvc/atoll-island/worker';
42
+ import { tickerContract } from '../islands/ticker.contract';
43
+
44
+ @Component()
45
+ class TickerIsland extends View {
46
+ /* signals, inject(), lifecycle — same as main thread */
47
+ }
48
+
49
+ export const worker = defineViewPolyWorker({
50
+ apps: { ticker: viewIslandApp(TickerIsland, { contract: tickerContract }) },
51
+ });
52
+ ```
53
+
54
+ ```tsx
55
+ // views/demo.view.tsx — the shell mount
56
+ import { islandComponent } from '@preact-mvc/atoll-island';
57
+ import { tickerContract } from '../islands/ticker.contract';
58
+
59
+ const TickerIsland = islandComponent(tickerContract);
60
+ // <TickerIsland onEvent={(e) => …} />
61
+ ```
62
+
63
+ ## Shell surface
64
+
65
+ - `<Island/>` — low-level mount element (driver + options by hand).
66
+ - `islandComponent(contract | app)` — the typed facade; props/emit/events
67
+ flow through the contract.
68
+ - `lazyIsland(loader)` — code-split a contract's mount.
69
+ - `viewIsland(App)` — mount a single component as a one-off island.
70
+
71
+ ## Worker surface (`@preact-mvc/atoll-island/worker`)
72
+
73
+ - `viewIslandApp(Component, { contract })` — the worker renderer: views
74
+ render against the proxy DOM, every mutation serializing to ops.
75
+ - `defineViewPolyWorker({ apps })` — one worker hosting many islands;
76
+ `defineViewMonoWorker` for a dedicated single app.
77
+
78
+ Published builds ship compiled `dist/` (`.js` + `.d.ts`).
@@ -0,0 +1,32 @@
1
+ import type { ComponentType } from 'preact';
2
+ import { type Provider } from '@preact-mvc/core';
3
+ import { type ImperativeIslandApp, type IslandContract, type IslandWorkerDef, type RenderedIslandApp } from '@preact-mvc/atoll/worker';
4
+ type AnyComponent = ComponentType<Record<string, unknown>>;
5
+ export interface ViewIslandAppOptions {
6
+ /** Providers for the island's root injector — the island-side equivalent
7
+ * of `bootstrap(AppModule, providers)`. */
8
+ providers?: Provider[];
9
+ /** Attach a `defineIslandContract` contract: mount/updateProps props are
10
+ * validated against its schema, and declared `emit` payloads are
11
+ * checked worker-side. */
12
+ contract?: IslandContract;
13
+ }
14
+ /** Wrap a View class or function component as a `RenderedIslandApp`. */
15
+ export declare function viewIslandApp(App: AnyComponent, opts?: ViewIslandAppOptions): RenderedIslandApp;
16
+ /** Stamp + wrap in one step — `viewIsland('counter', CounterView)` yields
17
+ * both the registry value and the name the shell mounts by. */
18
+ export declare const viewIsland: (name: string, App: AnyComponent, opts?: ViewIslandAppOptions) => RenderedIslandApp & {
19
+ readonly islandAppName: string;
20
+ };
21
+ export interface ViewPolyWorkerRegistry {
22
+ /** Name → component (or `{ imperative }` app / pre-wrapped app). Plain
23
+ * components pass through `viewIslandApp`. */
24
+ apps: Record<string, AnyComponent | ImperativeIslandApp | RenderedIslandApp>;
25
+ }
26
+ /** `definePolyWorker` for framework views — maps each component in the
27
+ * registry through `viewIslandApp` and delegates. */
28
+ export declare function defineViewPolyWorker(registry: ViewPolyWorkerRegistry): IslandWorkerDef;
29
+ /** `defineMonoWorker` for a single framework view — the isolated-bundle
30
+ * host shape (own worker, own failure domain). */
31
+ export declare function defineViewMonoWorker(app: AnyComponent | ImperativeIslandApp | RenderedIslandApp, opts?: ViewIslandAppOptions): IslandWorkerDef;
32
+ export {};
@@ -0,0 +1,174 @@
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 { Injector, InjectorContext } from '@preact-mvc/core';
23
+ import { defineMonoWorker, definePolyWorker, islandApp, islandAppNameOf, enterInstance, exitInstance, getActiveInstance, withContract, ProxyElement, } from '@preact-mvc/atoll/worker';
24
+ const ISLAND_KEY = '__islandInstance';
25
+ const PREV_KEY = '__islandPrevScope';
26
+ /**
27
+ * Instance-scoped rendering for OUT-OF-TASK renders. Mount/updateProps run
28
+ * inside `runInInstance`, but signal-driven re-renders are deferred by
29
+ * Preact to a microtask with no active scope — ambient `document` would
30
+ * resolve via the last-active heuristic and could create nodes on a SIBLING
31
+ * island's document (in shared-client/in-process topologies).
32
+ *
33
+ * Fix: every root vnode is stamped `__islandInstance`; `options.__r` walks
34
+ * the `__` parent chain before each component render and enters that
35
+ * instance's scope; `options.diffed` restores. Deterministic, and strictly
36
+ * better than the ambient heuristic.
37
+ */
38
+ let renderScopeInstalled = false;
39
+ function installRenderScope() {
40
+ if (renderScopeInstalled)
41
+ return;
42
+ renderScopeInstalled = true;
43
+ const opts = options;
44
+ const prevR = opts.__r;
45
+ const prevD = opts.diffed;
46
+ opts.__r = (vnode) => {
47
+ let v = vnode;
48
+ let key;
49
+ while (v) {
50
+ if (v[ISLAND_KEY]) {
51
+ key = v[ISLAND_KEY];
52
+ break;
53
+ }
54
+ v = v.__ ?? null;
55
+ }
56
+ if (key && key !== getActiveInstance())
57
+ vnode[PREV_KEY] = enterInstance(key);
58
+ prevR?.(vnode);
59
+ };
60
+ opts.diffed = (vnode) => {
61
+ prevD?.(vnode);
62
+ const v = vnode;
63
+ if (PREV_KEY in v) {
64
+ exitInstance(v[PREV_KEY] ?? null);
65
+ delete v[PREV_KEY];
66
+ }
67
+ };
68
+ }
69
+ let eventBridgeInstalled = false;
70
+ function installEventNameBridge() {
71
+ if (eventBridgeInstalled)
72
+ return;
73
+ eventBridgeInstalled = true;
74
+ /** element → original fn → prop-sliced type → wrapped fn */
75
+ const bridged = new WeakMap();
76
+ // Prop-sliced PascalCase → the real lowercase DOM type. Only the
77
+ // PascalCase shape is rewritten: 'MouseLeave' → 'mouseleave',
78
+ // 'DblClick' → 'dblclick'. All-caps ('DOMContentLoaded'), lowercase
79
+ // ('click'), and camelCase custom types pass through — lowercasing
80
+ // 'DOMContentLoaded' would break it the other way.
81
+ const decap = (type) => type.length > 1 &&
82
+ type[0] === type[0].toUpperCase() &&
83
+ type[1] === type[1].toLowerCase()
84
+ ? type.toLowerCase()
85
+ : type;
86
+ const proto = ProxyElement.prototype;
87
+ const origAdd = proto.addEventListener;
88
+ const origRemove = proto.removeEventListener;
89
+ proto.addEventListener = function (type, fn, opts) {
90
+ const dom = decap(type);
91
+ let effective = fn;
92
+ if (dom !== type) {
93
+ effective = function (e) {
94
+ fn.call(this, { ...e, type });
95
+ };
96
+ let byFn = bridged.get(this);
97
+ if (byFn === undefined)
98
+ bridged.set(this, (byFn = new Map()));
99
+ let byType = byFn.get(fn);
100
+ if (byType === undefined)
101
+ byFn.set(fn, (byType = new Map()));
102
+ byType.set(type, effective);
103
+ }
104
+ origAdd.call(this, dom, effective, opts);
105
+ };
106
+ proto.removeEventListener = function (type, fn, opts) {
107
+ const effective = bridged.get(this)?.get(fn)?.get(type) ?? fn;
108
+ origRemove.call(this, decap(type), effective, opts);
109
+ };
110
+ }
111
+ /** Wrap a View class or function component as a `RenderedIslandApp`. */
112
+ export function viewIslandApp(App, opts) {
113
+ installRenderScope();
114
+ installEventNameBridge();
115
+ const app = {
116
+ renderer: 'preact-mvc',
117
+ mount({ instance, doc, props }) {
118
+ const inj = new Injector(undefined, `island ${instance}`);
119
+ for (const p of opts?.providers ?? [])
120
+ inj.provide(p);
121
+ let current = props;
122
+ const renderRoot = (p) => {
123
+ current = p;
124
+ const vnode = h(InjectorContext.Provider, { value: inj }, h(App, p));
125
+ vnode[ISLAND_KEY] = instance;
126
+ render(vnode, doc.body);
127
+ };
128
+ renderRoot(props);
129
+ return {
130
+ update: renderRoot,
131
+ // The dispatch task's sync lane: run the event handler, then
132
+ // re-render synchronously (Preact's render() IS the flush — signal
133
+ // writes would otherwise defer to a microtask and their ops would
134
+ // miss this task's batch, surfacing a poll later).
135
+ sync: (fn) => {
136
+ fn();
137
+ renderRoot(current);
138
+ },
139
+ dispose: () => {
140
+ try {
141
+ render(null, doc.body);
142
+ }
143
+ finally {
144
+ inj.deactivate();
145
+ }
146
+ },
147
+ };
148
+ },
149
+ };
150
+ const stamp = islandAppNameOf(App);
151
+ if (stamp !== undefined)
152
+ app.islandAppName = stamp;
153
+ return opts?.contract !== undefined ? withContract(opts.contract, app) : app;
154
+ }
155
+ /** Stamp + wrap in one step — `viewIsland('counter', CounterView)` yields
156
+ * both the registry value and the name the shell mounts by. */
157
+ export const viewIsland = (name, App, opts) => islandApp(name, viewIslandApp(App, opts));
158
+ /** `definePolyWorker` for framework views — maps each component in the
159
+ * registry through `viewIslandApp` and delegates. */
160
+ export function defineViewPolyWorker(registry) {
161
+ installRenderScope();
162
+ const apps = {};
163
+ for (const [key, app] of Object.entries(registry.apps)) {
164
+ apps[key] = typeof app === 'function' ? viewIslandApp(app) : app;
165
+ }
166
+ return definePolyWorker({ apps });
167
+ }
168
+ /** `defineMonoWorker` for a single framework view — the isolated-bundle
169
+ * host shape (own worker, own failure domain). */
170
+ export function defineViewMonoWorker(app, opts) {
171
+ installRenderScope();
172
+ const resolved = typeof app === 'function' ? viewIslandApp(app, opts) : app;
173
+ return defineMonoWorker(resolved);
174
+ }
@@ -13,37 +13,6 @@
13
13
  * code only needs this one package.
14
14
  */
15
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';
16
+ export type { IslandAppRef, IslandFacadeProps, IslandProps, IslandShellProps, } from './shell';
17
+ export { callbackProp, connectIslandWorker, contractOf, contractWorkerOf, defineIslandContract, isIslandContract, islandApp, islandAppNameOf, mountIsland, withContract, z, } from '@preact-mvc/atoll';
18
+ export type { ConnectOptions, IslandAppProps, IslandClient, IslandContract, IslandContractEventHandler, IslandContractEvents, IslandContractProps, IslandHandle, IslandWorkerOptions, Mode, MountOptions, Schema, WorkerLike, } from '@preact-mvc/atoll';
package/dist/index.js ADDED
@@ -0,0 +1,16 @@
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 { callbackProp, connectIslandWorker, contractOf, contractWorkerOf, defineIslandContract, isIslandContract, islandApp, islandAppNameOf, mountIsland, withContract, z, } from '@preact-mvc/atoll';
@@ -0,0 +1,77 @@
1
+ import type { ComponentChildren, FunctionComponent } from 'preact';
2
+ import type { IslandAppProps, IslandClient, IslandContract, IslandContractEventHandler, IslandContractProps, IslandHandle, IslandWorkerOptions, Mode, WorkerLike } from '@preact-mvc/atoll';
3
+ /** Props consumed by a proxy component itself — everything else forwards to
4
+ * the island as its props. */
5
+ export interface IslandShellProps {
6
+ /** Bundler-detectable factory `() => new Worker(new URL(...))` (or a URL
7
+ * / spawned worker). Optional when `app` is a contract carrying its own
8
+ * worker — the contract module is then the whole connection. */
9
+ worker?: WorkerLike | (() => WorkerLike) | URL;
10
+ /** A shared `connectIslandWorker` client — this mount adds an instance. */
11
+ client?: IslandClient;
12
+ /** Client options for `worker`-shorthand mounts (taskTimeout, respawn,
13
+ * lazy, doorbell). `doorbell: true` needs cross-origin isolation. */
14
+ workerOptions?: IslandWorkerOptions & {
15
+ doorbell?: boolean;
16
+ };
17
+ /** 'push' rides the doorbell SAB (COOP/COEP); 'poll' (default) is
18
+ * message-only — no headers needed. */
19
+ mode?: Mode;
20
+ /** Devtools renderer tag. */
21
+ framework?: string;
22
+ /** Receives every worker-side `emit(name, payload)`. */
23
+ onEvent?: (name: string, payload: unknown) => void;
24
+ /** Fired after each applied op batch. */
25
+ onActivity?: () => void;
26
+ /** The mounted handle (pid, updateProps, flush, setMode…) once ready. */
27
+ onReady?: (island: IslandHandle) => void;
28
+ /** Mount/update errors surface here instead of an unhandled rejection. */
29
+ onError?: (err: unknown) => void;
30
+ /** `data-atoll-slot` transclusion holes: worker owns the box, shell owns
31
+ * the contents. */
32
+ slots?: Record<string, (el: HTMLElement | null) => void>;
33
+ /** Rendered while the worker mounts (lazy facades). */
34
+ fallback?: ComponentChildren;
35
+ className?: string;
36
+ style?: string | Record<string, string | number>;
37
+ mountTimeout?: number;
38
+ children?: ComponentChildren;
39
+ }
40
+ export interface IslandProps extends IslandShellProps {
41
+ /** Registry key, stamped app (`islandApp`/`viewIsland`), or contract —
42
+ * resolved through `islandAppNameOf`. */
43
+ app?: unknown;
44
+ /** Structured-cloneable props (functions via `callbackProp`). */
45
+ props?: Record<string, unknown>;
46
+ }
47
+ export declare const Island: FunctionComponent<IslandProps>;
48
+ /** What `app` accepts on a facade: the registry name, or a reference to
49
+ * resolve (component, stamped app, contract). */
50
+ export type IslandAppRef<A> = A | string;
51
+ export type IslandFacadeProps<A> = Omit<IslandShellProps, 'onEvent'> & IslandAppProps<A> & {
52
+ props?: Record<string, unknown>;
53
+ onEvent?: (name: string, payload: unknown) => void;
54
+ };
55
+ /**
56
+ * `islandComponent(contract)` — the cross-framework facade: the component's
57
+ * props are the island's props (from the contract's schema), `onEvent`
58
+ * narrows to the declared event vocabulary, and the contract's own worker
59
+ * factory means call sites pass NO worker/client at all. The shell imports
60
+ * the contract module only — never the worker entry or the island's code.
61
+ *
62
+ * `islandComponent<P>('charts')` — the string-keyed form for registries
63
+ * whose workers the shell already knows.
64
+ */
65
+ export declare function islandComponent<C extends IslandContract>(app: C): FunctionComponent<IslandContractProps<C> & Omit<IslandShellProps, 'onEvent'> & {
66
+ props?: Record<string, unknown>;
67
+ onEvent?: IslandContractEventHandler<C>;
68
+ }>;
69
+ export declare function islandComponent<A>(app: A | string): FunctionComponent<IslandFacadeProps<A>>;
70
+ /**
71
+ * `lazyIsland(() => import('./counter.contract'))` — the module must export
72
+ * a contract (`defineIslandContract` — app + worker factory in one object),
73
+ * a stamped app (`viewIsland('counter', CounterView)`), or a default export
74
+ * resolvable by `islandAppNameOf`. A contract's own worker factory is used
75
+ * when no `worker` prop is passed.
76
+ */
77
+ export declare function lazyIsland<A>(load: () => Promise<Record<string, unknown>>): FunctionComponent<IslandFacadeProps<A>>;
package/dist/shell.js ADDED
@@ -0,0 +1,152 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "preact/jsx-runtime";
2
+ import { useEffect, useRef, useState } from 'preact/hooks';
3
+ import { connectIslandWorker, contractWorkerOf, islandAppNameOf, isIslandContract, mountIsland, } from '@preact-mvc/atoll';
4
+ export const Island = (p) => {
5
+ const ref = useRef(null);
6
+ const handle = useRef(null);
7
+ const [failed, setFailed] = useState(null);
8
+ // 'main' mounts a mono worker's sole app namelessly; registries error
9
+ // with the key list if 'main' isn't a real key.
10
+ const appName = p.app === undefined ? 'main' : islandAppNameOf(p.app);
11
+ // Mount once per app — worker/client/app are identity; change via `key`.
12
+ useEffect(() => {
13
+ if (appName === undefined) {
14
+ setFailed(new Error('<Island> could not resolve an app name — pass the registry key ' +
15
+ 'or stamp the app with islandApp(name, app)'));
16
+ return;
17
+ }
18
+ const mode = p.mode ?? 'poll';
19
+ // A contract `app` can carry its own worker factory — the contract
20
+ // module is then the whole connection, call sites pass no worker.
21
+ const resolvedWorker = p.worker ?? contractWorkerOf(p.app);
22
+ const client = p.client ??
23
+ (resolvedWorker !== undefined
24
+ ? connectIslandWorker({
25
+ name: appName,
26
+ worker: resolvedWorker,
27
+ doorbell: mode !== 'poll',
28
+ ...p.workerOptions,
29
+ })
30
+ : undefined);
31
+ if (client === undefined) {
32
+ setFailed(new Error('<Island> requires either `worker`, `client`, or a contract carrying `worker`'));
33
+ return;
34
+ }
35
+ let cancelled = false;
36
+ mountIsland({
37
+ el: ref.current,
38
+ client,
39
+ app: appName,
40
+ props: p.props,
41
+ onEvent: p.onEvent,
42
+ onActivity: p.onActivity,
43
+ slots: p.slots,
44
+ mode,
45
+ framework: p.framework,
46
+ mountTimeout: p.mountTimeout,
47
+ })
48
+ .then((h) => {
49
+ if (cancelled)
50
+ h.destroy();
51
+ else {
52
+ handle.current = h;
53
+ p.onReady?.(h);
54
+ }
55
+ })
56
+ .catch((err) => {
57
+ if (cancelled)
58
+ return;
59
+ if (p.onError)
60
+ p.onError(err);
61
+ else
62
+ setFailed(err instanceof Error ? err : new Error(String(err)));
63
+ });
64
+ return () => {
65
+ cancelled = true;
66
+ handle.current?.destroy();
67
+ handle.current = null;
68
+ };
69
+ // eslint-disable-next-line react-hooks/exhaustive-deps
70
+ }, [appName, p.client]);
71
+ // Prop updates flow through the handle — deduped by wire-serialized
72
+ // identity; a repeat send would be a wasted round-trip.
73
+ const propsJson = JSON.stringify(p.props ?? {});
74
+ useEffect(() => {
75
+ handle.current?.updateProps(p.props ?? {});
76
+ // eslint-disable-next-line react-hooks/exhaustive-deps
77
+ }, [propsJson]);
78
+ if (failed) {
79
+ return (_jsxs("div", { class: "island-error", style: { border: '1px solid #c00', padding: '0.5em' }, children: ["Island mount failed: ", failed.message] }));
80
+ }
81
+ return _jsx("div", { ref: ref, class: p.className, style: p.style });
82
+ };
83
+ /** Props consumed by a facade component itself — everything else forwards
84
+ * to the island as its props. */
85
+ const SHELL_PROP_KEYS = new Set([
86
+ 'worker',
87
+ 'client',
88
+ 'workerOptions',
89
+ 'mode',
90
+ 'framework',
91
+ 'onEvent',
92
+ 'onActivity',
93
+ 'onReady',
94
+ 'onError',
95
+ 'slots',
96
+ 'fallback',
97
+ 'className',
98
+ 'style',
99
+ 'mountTimeout',
100
+ 'children',
101
+ ]);
102
+ const splitProxyProps = (raw) => {
103
+ const shell = {};
104
+ const islandProps = {};
105
+ for (const [key, value] of Object.entries(raw)) {
106
+ if (SHELL_PROP_KEYS.has(key))
107
+ shell[key] = value;
108
+ else
109
+ islandProps[key] = value;
110
+ }
111
+ return { shell: shell, islandProps };
112
+ };
113
+ export function islandComponent(app) {
114
+ const Facade = ({ props, ...raw }) => {
115
+ const { shell, islandProps } = splitProxyProps(raw);
116
+ return _jsx(Island, { app: app, props: { ...islandProps, ...(props ?? {}) }, ...shell });
117
+ };
118
+ Facade.displayName = `Island(${islandAppNameOf(app) ?? 'main'})`;
119
+ return Facade;
120
+ }
121
+ /**
122
+ * `lazyIsland(() => import('./counter.contract'))` — the module must export
123
+ * a contract (`defineIslandContract` — app + worker factory in one object),
124
+ * a stamped app (`viewIsland('counter', CounterView)`), or a default export
125
+ * resolvable by `islandAppNameOf`. A contract's own worker factory is used
126
+ * when no `worker` prop is passed.
127
+ */
128
+ export function lazyIsland(load) {
129
+ const Lazy = (p) => {
130
+ const [app, setApp] = useState(null);
131
+ useEffect(() => {
132
+ let live = true;
133
+ void load().then((mod) => {
134
+ if (!live)
135
+ return;
136
+ const unwrapped = 'default' in mod ? mod.default : mod;
137
+ const resolved = isIslandContract(unwrapped) ? unwrapped
138
+ : Object.values(mod).find((v) => isIslandContract(v)) ??
139
+ Object.values(mod).find((v) => v && typeof v === 'object' && 'islandAppName' in v) ??
140
+ unwrapped;
141
+ setApp(() => resolved);
142
+ });
143
+ return () => {
144
+ live = false;
145
+ };
146
+ }, []);
147
+ if (!app)
148
+ return _jsx("div", { class: p.className, style: p.style, children: p.fallback });
149
+ return _jsx(Island, { ...p, app: app });
150
+ };
151
+ return Lazy;
152
+ }
@@ -12,12 +12,7 @@
12
12
  * `@preact-mvc/atoll/worker` so a worker entry needs no direct atoll-core
13
13
  * import (same convention as Atoll's `*-island` packages).
14
14
  */
15
- export {
16
- viewIslandApp,
17
- viewIsland,
18
- defineViewPolyWorker,
19
- defineViewMonoWorker,
20
- } from './adapter';
15
+ export { viewIslandApp, viewIsland, defineViewPolyWorker, defineViewMonoWorker, } from './adapter';
21
16
  export type { ViewIslandAppOptions, ViewPolyWorkerRegistry } from './adapter';
22
17
  /**
23
18
  * The mount components on the worker side — same surface as the shell
@@ -28,39 +23,7 @@ export type { ViewIslandAppOptions, ViewPolyWorkerRegistry } from './adapter';
28
23
  * client; `emit` in a nested `onEvent` relays events to the page.
29
24
  */
30
25
  export { Island, islandComponent, lazyIsland } from './shell';
31
- export type {
32
- IslandProps,
33
- IslandShellProps,
34
- IslandAppRef,
35
- IslandFacadeProps,
36
- } from './shell';
26
+ export type { IslandProps, IslandShellProps, IslandAppRef, IslandFacadeProps, } from './shell';
37
27
  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';
28
+ export { contractOf, defineIslandContract, definePolyWorker, defineMonoWorker, emit, installDomShim, isIslandContract, runInInstance, enterInstance, exitInstance, getActiveInstance, getLastActiveInstance, islandApp, islandAppNameOf, withContract, ProxyElement, } from '@preact-mvc/atoll/worker';
29
+ export type { EventPayload, ImperativeIslandApp, IslandContract, RenderedIslandApp, RenderContext, RenderedHandle, IslandApp, IslandWorkerDef, ProxyDocument, } from '@preact-mvc/atoll/worker';
package/dist/worker.js ADDED
@@ -0,0 +1,26 @@
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 { viewIslandApp, viewIsland, defineViewPolyWorker, defineViewMonoWorker, } from './adapter';
16
+ /**
17
+ * The mount components on the worker side — same surface as the shell
18
+ * entry (Atoll's `react-island/worker` convention): inside a worker-
19
+ * rendered tree the component's div is a ProxyElement, `mountIsland`
20
+ * takes the nested path, and the sub-island's ops tunnel up the parent
21
+ * instance's op stream. `connectIslandWorker` builds a shared sub-worker
22
+ * client; `emit` in a nested `onEvent` relays events to the page.
23
+ */
24
+ export { Island, islandComponent, lazyIsland } from './shell';
25
+ export { connectIslandWorker, mountIsland, callbackProp } from '@preact-mvc/atoll/worker';
26
+ export { contractOf, defineIslandContract, definePolyWorker, defineMonoWorker, emit, installDomShim, isIslandContract, runInInstance, enterInstance, exitInstance, getActiveInstance, getLastActiveInstance, islandApp, islandAppNameOf, withContract, ProxyElement, } from '@preact-mvc/atoll/worker';
package/package.json CHANGED
@@ -1,25 +1,30 @@
1
1
  {
2
2
  "name": "@preact-mvc/atoll-island",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "preact-mvc island adapter: <Island/> shell components + Preact renderer for @preact-mvc/atoll worker islands",
5
5
  "type": "module",
6
6
  "exports": {
7
- ".": "./src/index.ts",
8
- "./worker": "./src/worker.ts",
7
+ ".": {
8
+ "types": "./dist/index.d.ts",
9
+ "default": "./dist/index.js"
10
+ },
11
+ "./worker": {
12
+ "types": "./dist/worker.d.ts",
13
+ "default": "./dist/worker.js"
14
+ },
9
15
  "./package.json": "./package.json",
10
- "./*": "./src/*"
16
+ "./*": "./dist/*"
11
17
  },
12
18
  "files": [
13
- "src",
14
- "!src/**/*.test.ts",
15
- "!src/**/*.test.tsx"
19
+ "dist"
16
20
  ],
17
21
  "scripts": {
18
- "typecheck": "tsc --noEmit"
22
+ "typecheck": "tsc --noEmit",
23
+ "build": "tsc -p tsconfig.build.json"
19
24
  },
20
25
  "dependencies": {
21
- "@preact-mvc/atoll": "0.1.0",
22
- "@preact-mvc/core": "0.1.0",
26
+ "@preact-mvc/atoll": "0.1.1",
27
+ "@preact-mvc/core": "0.1.1",
23
28
  "preact": "^10.24.0"
24
29
  },
25
30
  "devDependencies": {
@@ -35,5 +40,6 @@
35
40
  "url": "https://github.com/jwhenry3/preact-mvc",
36
41
  "directory": "packages/atoll-island"
37
42
  },
38
- "license": "MIT"
43
+ "license": "MIT",
44
+ "types": "./dist/index.d.ts"
39
45
  }
package/src/adapter.tsx DELETED
@@ -1,258 +0,0 @@
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/shell.tsx DELETED
@@ -1,280 +0,0 @@
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
- }