@atolljs/islands 0.1.1 → 0.1.3

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Justin Henry
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,11 +1,15 @@
1
1
  # @atolljs/islands
2
2
 
3
+ [![CI](https://github.com/jwhenry3/atolljs/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/jwhenry3/atolljs/actions/workflows/ci.yml)
4
+ [![codecov](https://codecov.io/gh/jwhenry3/atolljs/graph/badge.svg?branch=main)](https://codecov.io/gh/jwhenry3/atolljs)
5
+ [![Socket Badge](https://badge.socket.dev/npm/package/@atolljs/islands)](https://badge.socket.dev/npm/package/@atolljs/islands)
6
+
3
7
  Framework islands inside Atoll workers — opt-in DOM rendering off the main
4
8
  thread. A real framework renderer commits against a DOM-free host surface in
5
9
  the worker; every commit serializes to an op stream the main thread replays as
6
10
  DOM mutations.
7
11
 
8
- **[Atoll — package documentation](https://jwhenry3.github.io/atolljs/consumer/#/quickstart)**
12
+ **[Atoll — package documentation](https://jwhenry3.github.io/atolljs/consumer/quickstart/)**
9
13
 
10
14
  The vocabulary:
11
15
 
@@ -140,10 +144,10 @@ const island = await mountIsland({ worker: () => new Worker(url, { type: 'module
140
144
 
141
145
  ## Documentation
142
146
 
143
- - [Atoll — package documentation](https://jwhenry3.github.io/atolljs/consumer/#/quickstart)
144
- - [Worker islands](https://jwhenry3.github.io/atolljs/consumer/#/islands) —
147
+ - [Atoll — package documentation](https://jwhenry3.github.io/atolljs/consumer/quickstart/)
148
+ - [Worker islands](https://jwhenry3.github.io/atolljs/consumer/islands/) —
145
149
  mountIsland options, `IslandHandle`, transport modes
146
- - [Writing island apps](https://jwhenry3.github.io/atolljs/consumer/#/island-apps) —
150
+ - [Writing island apps](https://jwhenry3.github.io/atolljs/consumer/island-apps/) —
147
151
  emit/callbackProp/slots, instance discipline
148
152
  - In-repo internals: [`docs/islands.md`](../../docs/islands.md),
149
153
  [`docs/islands-worker.md`](../../docs/islands-worker.md),
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@atolljs/islands",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Atoll islands — MonoWorker/PolyWorker hosts rendering framework islands (React/Vue/Svelte/Solid/Angular) over a proxy-DOM op protocol",
5
- "homepage": "https://jwhenry3.github.io/atolljs/consumer/#/quickstart",
5
+ "homepage": "https://jwhenry3.github.io/atolljs/consumer/quickstart/",
6
6
  "type": "module",
7
7
  "exports": {
8
8
  ".": "./src/index.ts",
@@ -19,7 +19,7 @@
19
19
  "htmlparser2": "^12.0.0"
20
20
  },
21
21
  "peerDependencies": {
22
- "@atolljs/core": "0.1.1"
22
+ "@atolljs/core": "0.1.3"
23
23
  },
24
24
  "repository": {
25
25
  "type": "git",
@@ -28,5 +28,6 @@
28
28
  },
29
29
  "bugs": {
30
30
  "url": "https://github.com/jwhenry3/atolljs/issues"
31
- }
31
+ },
32
+ "license": "MIT"
32
33
  }
package/src/island.ts CHANGED
@@ -299,6 +299,40 @@ const assertCloneableProps = (props: Record<string, unknown>, context: string):
299
299
  );
300
300
  };
301
301
 
302
+ /* ── Style values ─────────────────────────────────────────────────────────
303
+ * Style objects cross the wire verbatim (`style={{ height: 24000000 }}`),
304
+ * so the driver must apply the same unit semantics react-dom does:
305
+ * a nonzero number on a non-unitless property needs 'px' — assigning the
306
+ * bare number to el.style silently no-ops on every real browser.
307
+ * The list is React DOM's isUnitlessNumber set (which is also the CSS spec's
308
+ * unitless property surface). */
309
+ const UNITLESS_CSS = new Set([
310
+ 'animationIterationCount', 'aspectRatio', 'borderImageOutset',
311
+ 'borderImageSlice', 'borderImageWidth', 'boxFlex', 'boxFlexGroup',
312
+ 'boxOrdinalGroup', 'columnCount', 'columns', 'flex', 'flexGrow',
313
+ 'flexPositive', 'flexShrink', 'flexNegative', 'flexOrder', 'gridArea',
314
+ 'gridRow', 'gridRowEnd', 'gridRowSpan', 'gridRowStart', 'gridColumn',
315
+ 'gridColumnEnd', 'gridColumnSpan', 'gridColumnStart', 'fontWeight',
316
+ 'lineClamp', 'lineHeight', 'opacity', 'order', 'orphans', 'scale',
317
+ 'tabSize', 'widows', 'zIndex', 'zoom', 'fillOpacity', 'floodOpacity',
318
+ 'stopOpacity', 'strokeDasharray', 'strokeDashoffset', 'strokeMiterlimit',
319
+ 'strokeOpacity', 'strokeWidth',
320
+ ]);
321
+
322
+ /** Coerce a wire style value to a CSS string — px-suffixes bare numbers
323
+ * on non-unitless keys (custom properties pass verbatim). */
324
+ const cssStyleValue = (key: string, value: unknown): string => {
325
+ if (
326
+ typeof value === 'number' &&
327
+ value !== 0 &&
328
+ !key.startsWith('--') &&
329
+ !UNITLESS_CSS.has(key)
330
+ ) {
331
+ return `${value}px`;
332
+ }
333
+ return String(value);
334
+ };
335
+
302
336
  export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandle> {
303
337
  const {
304
338
  el, onEvent, onActivity, onOps, slots, mode: initialMode, mountTimeout,
@@ -436,11 +470,17 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
436
470
  return;
437
471
  }
438
472
  if (name === 'style' && typeof value === 'object' && value !== null) {
439
- const elStyle = (el as HTMLElement).style as unknown as Record<string, string>;
473
+ const decl = (el as HTMLElement).style;
474
+ const elStyle = decl as unknown as Record<string, string>;
440
475
  const prevStyle = (prevProps.get(id)?.style ?? {}) as Record<string, string>;
441
- const nextStyle = value as Record<string, string>;
476
+ const nextStyle = value as Record<string, unknown>;
442
477
  for (const k of Object.keys(prevStyle)) if (!(k in nextStyle)) elStyle[k] = '';
443
- for (const [k, v] of Object.entries(nextStyle)) if (prevStyle[k] !== v) elStyle[k] = v;
478
+ for (const [k, v] of Object.entries(nextStyle)) {
479
+ if (prevStyle[k] !== v) {
480
+ if (k.startsWith('--') || k.includes('-')) decl.setProperty(k, cssStyleValue(k, v));
481
+ else elStyle[k] = cssStyleValue(k, v);
482
+ }
483
+ }
444
484
  return;
445
485
  }
446
486
  if (name === 'className') {
@@ -555,7 +595,12 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
555
595
  }
556
596
  if (name in node) {
557
597
  try {
558
- (node as unknown as Record<string, unknown>)[name] = value;
598
+ // Presence-attr semantics: the proxy emits attr{name,''} for boolean
599
+ // attributes (checked, disabled…) — assigning '' would read falsy.
600
+ // A boolean live property takes true whenever the attr is present.
601
+ const cur = (node as unknown as Record<string, unknown>)[name];
602
+ (node as unknown as Record<string, unknown>)[name] =
603
+ typeof cur === 'boolean' ? true : value;
559
604
  return;
560
605
  } catch {
561
606
  /* read-only property — use the attribute */
@@ -576,17 +621,18 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
576
621
  const prev = prevProps.get(id) ?? {};
577
622
  const merged = { ...((prev.style ?? {}) as Record<string, string>) };
578
623
  for (const [k, v] of Object.entries(changes)) {
624
+ const cv = cssStyleValue(k, v);
579
625
  if (k.startsWith('--') || k.includes('-')) {
580
626
  // Custom properties and kebab-case keys live outside the camelCase
581
627
  // property surface — el.style['font-size'] is a silent no-op on real
582
628
  // DOM; setProperty accepts kebab-case, and '' clears either kind.
583
- elStyle.setProperty(k, v);
584
- } else if (v.endsWith('!important')) {
629
+ elStyle.setProperty(k, cv);
630
+ } else if (cv.endsWith('!important')) {
585
631
  // `style.setProperty(k, v, 'important')` crosses as a suffix — the
586
632
  // style op has no separate priority channel.
587
- elStyle.setProperty(k, v.replace(/\s*!important$/, ''), 'important');
633
+ elStyle.setProperty(k, cv.replace(/\s*!important$/, ''), 'important');
588
634
  } else {
589
- elStyleMap[k] = v;
635
+ elStyleMap[k] = cv;
590
636
  }
591
637
  merged[k] = v;
592
638
  }
@@ -707,6 +753,10 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
707
753
  // node maps must survive this op: the following `append` still needs
708
754
  // the ids it just created. Stale ids are never reused; the only cost
709
755
  // is a few dead map entries on remount.
756
+ // Slot anchors inside get no remove op of their own — unmount them
757
+ // like a `remove` op would so slots callbacks see null and
758
+ // slotNodes doesn't leak.
759
+ unmountSlotSubtree(el);
710
760
  el.replaceChildren();
711
761
  break;
712
762
  }
@@ -897,7 +947,12 @@ export async function mountIsland(opts: MountIslandOptions): Promise<IslandHandl
897
947
  void client
898
948
  .unmount(instance)
899
949
  .catch(() => {})
900
- .finally(() => client.terminate());
950
+ .finally(() => {
951
+ // A remount may have registered while teardown was in flight
952
+ // (app/client input swap does destroy → mount back-to-back) —
953
+ // terminate only when the client is still truly idle.
954
+ if ((clientMounts.get(client) ?? 0) === 0) client.terminate();
955
+ });
901
956
  }
902
957
  },
903
958
  };