@preact-mvc/atoll-island 0.1.0 → 0.1.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.
- package/README.md +78 -0
- package/dist/adapter.d.ts +32 -0
- package/dist/adapter.js +174 -0
- package/{src/index.ts → dist/index.d.ts} +4 -35
- package/dist/index.js +16 -0
- package/dist/shell.d.ts +77 -0
- package/dist/shell.js +152 -0
- package/{src/worker.ts → dist/worker.d.ts} +4 -41
- package/dist/worker.js +26 -0
- package/package.json +18 -11
- package/src/adapter.tsx +0 -258
- package/src/shell.tsx +0 -280
package/README.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# @preact-mvc/atoll-island
|
|
2
|
+
|
|
3
|
+
[](https://github.com/jwhenry3/preact-mvc/actions/workflows/ci.yml)
|
|
4
|
+
[](https://codecov.io/gh/jwhenry3/preact-mvc)
|
|
5
|
+
[](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
|
+
**[PreactMVC — 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 {};
|
package/dist/adapter.js
ADDED
|
@@ -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
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `@preact-mvc/atoll-island` — the `*-island` package's shell surface
|
|
3
3
|
* (docs/atoll/islands-frameworks.md): mount a worker-hosted island tree as
|
|
4
|
-
* an ordinary element in a
|
|
4
|
+
* an ordinary element in a PreactMVC app. The worker-side renderer
|
|
5
5
|
* (viewIslandApp, defineViewPolyWorker) lives in the `./worker` entry.
|
|
6
6
|
*
|
|
7
7
|
* The facade pattern: a contract module (`defineIslandContract`) names the
|
|
@@ -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
|
-
|
|
18
|
-
|
|
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 PreactMVC 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';
|
package/dist/shell.d.ts
ADDED
|
@@ -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
|
-
|
|
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,31 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@preact-mvc/atoll-island",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "preact-mvc island adapter: <Island/> shell components + Preact renderer for @preact-mvc/atoll worker islands",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
6
7
|
"exports": {
|
|
7
|
-
".":
|
|
8
|
-
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"./worker": {
|
|
13
|
+
"types": "./dist/worker.d.ts",
|
|
14
|
+
"default": "./dist/worker.js"
|
|
15
|
+
},
|
|
9
16
|
"./package.json": "./package.json",
|
|
10
|
-
"./*": "./
|
|
17
|
+
"./*": "./dist/*"
|
|
11
18
|
},
|
|
12
19
|
"files": [
|
|
13
|
-
"
|
|
14
|
-
"!src/**/*.test.ts",
|
|
15
|
-
"!src/**/*.test.tsx"
|
|
20
|
+
"dist"
|
|
16
21
|
],
|
|
17
22
|
"scripts": {
|
|
18
|
-
"typecheck": "tsc --noEmit"
|
|
23
|
+
"typecheck": "tsc --noEmit",
|
|
24
|
+
"build": "tsc -p tsconfig.build.json"
|
|
19
25
|
},
|
|
20
26
|
"dependencies": {
|
|
21
|
-
"@preact-mvc/atoll": "0.1.
|
|
22
|
-
"@preact-mvc/core": "0.1.
|
|
27
|
+
"@preact-mvc/atoll": "0.1.2",
|
|
28
|
+
"@preact-mvc/core": "0.1.2",
|
|
23
29
|
"preact": "^10.24.0"
|
|
24
30
|
},
|
|
25
31
|
"devDependencies": {
|
|
@@ -35,5 +41,6 @@
|
|
|
35
41
|
"url": "https://github.com/jwhenry3/preact-mvc",
|
|
36
42
|
"directory": "packages/atoll-island"
|
|
37
43
|
},
|
|
38
|
-
"license": "MIT"
|
|
44
|
+
"license": "MIT",
|
|
45
|
+
"types": "./dist/index.d.ts"
|
|
39
46
|
}
|
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
|
-
}
|