@panyam/tsappkit 0.3.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panyam/tsappkit",
3
- "version": "0.3.0",
3
+ "version": "0.6.0",
4
4
  "description": "TypeScript application toolkit with component lifecycle management, event system, UI utilities, and documentation site components",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
package/src/index.ts CHANGED
@@ -31,8 +31,10 @@ export { MobileBottomDrawer } from './MobileBottomDrawer';
31
31
  export { IslandPage } from './page/IslandPage';
32
32
  export { readSpec, SPEC_ELEMENT_ID } from './page/spec';
33
33
  export type { IslandSpec, PageSpec, SpecExtension } from './page/spec';
34
- export { mountIslands } from './page/mount';
35
- export type { IslandFactory, Registry } from './page/mount';
34
+ export { lazy, mountIslands } from './page/mount';
35
+ export type { IslandFactory, LazyIsland, MountOptions, Registry } from './page/mount';
36
+ export { parseLoad, scheduleMount } from './page/load';
37
+ export type { LoadEnv, LoadStrategy } from './page/load';
36
38
 
37
39
  // Utilities
38
40
  export { isInInputContext, hasModifierKeys, shouldIgnoreShortcut } from './DOMUtils';
@@ -1,6 +1,8 @@
1
1
  import { BasePage } from "../BasePage";
2
2
  import type { EventBus } from "../EventBus";
3
3
  import type { LCMComponent } from "../LCMComponent";
4
+ import { LifecycleController } from "../LifecycleController";
5
+ import { parseLoad, scheduleMount, type LoadStrategy } from "./load";
4
6
  import { mountIslands, type Registry } from "./mount";
5
7
  import { readSpec, SPEC_ELEMENT_ID, type PageSpec } from "./spec";
6
8
 
@@ -15,6 +17,13 @@ import { readSpec, SPEC_ELEMENT_ID, type PageSpec } from "./spec";
15
17
  * fields in `readExtension`, and they arrive typed as `Ext` on the spec
16
18
  * `makeContext` gets.
17
19
  *
20
+ * Each island mounts when its `load` says (`eager`, `idle`, `visible`,
21
+ * `media:<query>`; see scheduleMount), and a `lazy` registry entry loads its
22
+ * chunk then. One that mounts later, a lazy eager one included, goes through
23
+ * its own LifecycleController, so it still gets performLocalInit,
24
+ * setupDependencies and activate. A deferred island mustn't be something
25
+ * another island or the page needs at startup: nothing waits for it.
26
+ *
18
27
  * A page with no readable `#page-spec` mounts nothing and warns. Subclasses
19
28
  * that override initializeSpecificComponents call super and add to what it
20
29
  * returns.
@@ -44,6 +53,22 @@ export abstract class IslandPage<Ctx, Ext extends object = {}> extends BasePage
44
53
  () => this.makeContext(spec),
45
54
  this.eventBus,
46
55
  (message) => console.warn(message),
56
+ {
57
+ defer: (island, el, mount) => this.scheduleLoad(parseLoad(island.load) ?? { kind: "eager" }, el, mount),
58
+ onLateMount: (component, island) => {
59
+ new LifecycleController(this.eventBus, LifecycleController.DefaultConfig)
60
+ .initializeFromRoot(component)
61
+ .catch((err) => console.warn(`page spec: island "${island.name}" failed to start: ${err instanceof Error ? err.message : String(err)}`));
62
+ },
63
+ },
47
64
  );
48
65
  }
66
+
67
+ /**
68
+ * Waits for `strategy` and then calls `mount`. Defaults to scheduleMount
69
+ * against the window; a subclass or test can replace how the waiting is done.
70
+ */
71
+ protected scheduleLoad(strategy: LoadStrategy, el: HTMLElement, mount: () => void): void {
72
+ scheduleMount(strategy, el, mount);
73
+ }
49
74
  }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * When an island mounts: its `load` in the page spec (goapplib's
3
+ * page.Island.Load), read and waited for. Every strategy mounts once; an
4
+ * island isn't unmounted when its media query stops matching.
5
+ */
6
+
7
+ /** A parsed `load`. An empty or missing one is eager. */
8
+ export type LoadStrategy =
9
+ | { kind: "eager" }
10
+ | { kind: "idle" }
11
+ | { kind: "visible" }
12
+ | { kind: "media"; query: string };
13
+
14
+ /**
15
+ * The strategy `load` names, or null when it isn't one of the forms Go's
16
+ * Validate accepts (`eager`, `idle`, `visible`, `media:<query>`), compared
17
+ * exactly.
18
+ */
19
+ export function parseLoad(load: string | undefined): LoadStrategy | null {
20
+ switch (load ?? "") {
21
+ case "":
22
+ case "eager":
23
+ return { kind: "eager" };
24
+ case "idle":
25
+ return { kind: "idle" };
26
+ case "visible":
27
+ return { kind: "visible" };
28
+ }
29
+ const query = load!.startsWith("media:") ? load!.slice("media:".length) : "";
30
+ return query.trim() ? { kind: "media", query } : null;
31
+ }
32
+
33
+ /**
34
+ * The browser APIs scheduleMount waits on, so tests can stand them in.
35
+ * `requestIdleCallback` is optional because Safari doesn't have it.
36
+ */
37
+ export interface LoadEnv {
38
+ requestIdleCallback?: (cb: () => void) => unknown;
39
+ setTimeout: (cb: () => void, ms?: number) => unknown;
40
+ IntersectionObserver: typeof IntersectionObserver;
41
+ matchMedia: (query: string) => MediaQueryList;
42
+ }
43
+
44
+ /**
45
+ * Calls `mount` once, when `strategy` says the island in `el` should mount:
46
+ * at once for eager; when the browser is idle (or on the next task, without
47
+ * requestIdleCallback) for idle; the first time any of `el` enters the
48
+ * viewport for visible; and at once if the query matches, or else on the
49
+ * first change that makes it match, for media.
50
+ */
51
+ export function scheduleMount(strategy: LoadStrategy, el: Element, mount: () => void, env: LoadEnv = window): void {
52
+ switch (strategy.kind) {
53
+ case "eager":
54
+ mount();
55
+ return;
56
+ case "idle":
57
+ if (env.requestIdleCallback) env.requestIdleCallback(mount);
58
+ else env.setTimeout(mount, 1);
59
+ return;
60
+ case "visible": {
61
+ let done = false;
62
+ const observer = new env.IntersectionObserver((entries) => {
63
+ if (done || !entries.some((e) => e.isIntersecting)) return;
64
+ done = true;
65
+ observer.disconnect();
66
+ mount();
67
+ });
68
+ observer.observe(el);
69
+ return;
70
+ }
71
+ case "media": {
72
+ const mq = env.matchMedia(strategy.query);
73
+ if (mq.matches) {
74
+ mount();
75
+ return;
76
+ }
77
+ const onChange = (e: { matches: boolean }) => {
78
+ if (!e.matches) return;
79
+ mq.removeEventListener("change", onChange);
80
+ mount();
81
+ };
82
+ mq.addEventListener("change", onChange);
83
+ return;
84
+ }
85
+ }
86
+ }
package/src/page/mount.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { parseLoad } from "./load";
1
2
  import type { IslandSpec, PageSpec } from "./spec";
2
3
 
3
4
  /**
@@ -6,18 +7,65 @@ import type { IslandSpec, PageSpec } from "./spec";
6
7
  */
7
8
  export type IslandFactory<Ctx, El, C, B> = (el: El, island: IslandSpec, ctx: Ctx, bus: B) => C;
8
9
 
9
- /** The islands an entry can mount, by name. An entry bundles only what its registry names. */
10
- export type Registry<Ctx, El, C, B> = Record<string, IslandFactory<Ctx, El, C, B>>;
10
+ const LAZY: unique symbol = Symbol("lazy island");
11
+
12
+ /** A registry entry whose factory is loaded when the island mounts. Made by lazy. */
13
+ export interface LazyIsland<Ctx, El, C, B> {
14
+ readonly [LAZY]: () => Promise<IslandFactory<Ctx, El, C, B> | { default: IslandFactory<Ctx, El, C, B> }>;
15
+ }
16
+
17
+ /**
18
+ * A registry entry that loads its island's module only when the island
19
+ * mounts: `hero: lazy(() => import("./islands/hero"))`. With esbuild's
20
+ * --splitting each such module is its own chunk, so a page downloads only the
21
+ * islands its spec names, each when its `load` says. `load` resolves to the
22
+ * factory or to a module whose default export is the factory.
23
+ *
24
+ * A lazy island always mounts late, even an eager one, since its chunk
25
+ * arrives after the page has started; goapplib's page.Assets writes
26
+ * modulepreload links for the eager ones so that wait is short.
27
+ */
28
+ export function lazy<Ctx, El, C, B>(
29
+ load: () => Promise<IslandFactory<Ctx, El, C, B> | { default: IslandFactory<Ctx, El, C, B> }>,
30
+ ): LazyIsland<Ctx, El, C, B> {
31
+ return { [LAZY]: load };
32
+ }
33
+
34
+ /**
35
+ * The islands an entry can mount, by name: a factory, bundled with the entry,
36
+ * or a lazy entry, loaded as its own chunk when the island mounts.
37
+ */
38
+ export type Registry<Ctx, El, C, B> = Record<string, IslandFactory<Ctx, El, C, B> | LazyIsland<Ctx, El, C, B>>;
39
+
40
+ /** How mountIslands handles islands that shouldn't mount at once. */
41
+ export interface MountOptions<El, C> {
42
+ /**
43
+ * Gets each island whose `load` isn't eager, with the slot it will mount
44
+ * in; call `mount` when it's time (IslandPage passes scheduleMount). An
45
+ * island with a `load` parseLoad doesn't know is logged and mounted at once
46
+ * instead, so it still shows up.
47
+ */
48
+ defer?: (island: IslandSpec, el: El, mount: () => void) => void;
49
+ /** Gets what the factory built for each island mounted later: through `defer`, or from a lazy entry. */
50
+ onLateMount?: (component: C, island: IslandSpec) => void;
51
+ }
11
52
 
12
53
  /**
13
54
  * Mounts every island in `spec` into the element `findSlot` gives for its
14
- * slot, and returns what the factories built, in spec order. `context` builds
15
- * the page's shared services; it's called once, before the first island
16
- * mounts, and not at all on a page with nothing to mount, so a page without
17
- * islands doesn't start what they'd share. An island the registry doesn't
18
- * know, a slot that isn't on the page, or a factory that throws is reported
19
- * through `log` and skipped, so one bad entry doesn't take the rest of the
20
- * page down with it.
55
+ * slot, and returns what the factories built at once, in spec order. With
56
+ * `options.defer`, an island whose `load` isn't eager is handed to it instead
57
+ * and reported through `options.onLateMount` when it mounts; without it,
58
+ * every island mounts now whatever its `load` says. A lazy entry is loaded
59
+ * when its island would mount and is reported through `onLateMount` too, so
60
+ * it's never in the returned list.
61
+ *
62
+ * `context` builds the page's shared services; it's called once, before the
63
+ * first island mounts (eager or late), and not at all on a page with nothing
64
+ * to mount, so a page without islands doesn't start what they'd share. An
65
+ * island the registry doesn't know, a slot that isn't on the page, or a
66
+ * factory that throws (now or later), or a lazy entry that fails to load, is
67
+ * reported through `log` and skipped,
68
+ * so one bad entry doesn't take the rest of the page down with it.
21
69
  *
22
70
  * Plain types throughout (no DOM), so it runs under node in tests and on a
23
71
  * bare page without BasePage.
@@ -29,12 +77,13 @@ export function mountIslands<Ctx, El, C, B>(
29
77
  context: () => Ctx,
30
78
  bus: B,
31
79
  log: (message: string) => void,
80
+ options: MountOptions<El, C> = {},
32
81
  ): C[] {
33
82
  const out: C[] = [];
34
83
  let ctx: Ctx | undefined;
35
84
  for (const island of spec.islands) {
36
- const factory = Object.prototype.hasOwnProperty.call(registry, island.name) ? registry[island.name] : undefined;
37
- if (!factory) {
85
+ const entry = Object.prototype.hasOwnProperty.call(registry, island.name) ? registry[island.name] : undefined;
86
+ if (!entry) {
38
87
  log(`page spec: no island called "${island.name}" in this page's registry`);
39
88
  continue;
40
89
  }
@@ -43,12 +92,52 @@ export function mountIslands<Ctx, El, C, B>(
43
92
  log(`page spec: island "${island.name}" wants slot "${island.slot}", which isn't on the page`);
44
93
  continue;
45
94
  }
46
- try {
47
- ctx ??= context();
48
- out.push(factory(el, island, ctx, bus));
49
- } catch (err) {
50
- log(`page spec: island "${island.name}" failed to mount: ${err instanceof Error ? err.message : String(err)}`);
95
+ const build = (factory: IslandFactory<Ctx, El, C, B>): C | undefined => {
96
+ try {
97
+ ctx ??= context();
98
+ return factory(el, island, ctx, bus);
99
+ } catch (err) {
100
+ log(`page spec: island "${island.name}" failed to mount: ${message(err)}`);
101
+ return undefined;
102
+ }
103
+ };
104
+ const mountLate = () => {
105
+ const late = (factory: IslandFactory<Ctx, El, C, B>) => {
106
+ const c = build(factory);
107
+ if (c !== undefined) options.onLateMount?.(c, island);
108
+ };
109
+ if (typeof entry === "function") {
110
+ late(entry);
111
+ return;
112
+ }
113
+ // The executor turns a loader that throws into a rejection, logged like any other.
114
+ new Promise<Awaited<ReturnType<(typeof entry)[typeof LAZY]>>>((resolve) => resolve(entry[LAZY]())).then(
115
+ (m) => {
116
+ const factory = typeof m === "function" ? m : m?.default;
117
+ if (typeof factory === "function") late(factory);
118
+ else log(`page spec: island "${island.name}" loaded, but its module has no factory (a default export or the function itself)`);
119
+ },
120
+ (err) => log(`page spec: island "${island.name}" failed to load: ${message(err)}`),
121
+ );
122
+ };
123
+ const strategy = parseLoad(island.load);
124
+ if (strategy === null) {
125
+ log(`page spec: island "${island.name}" has load "${island.load}", which isn't eager, idle, visible or media:<query>; mounting it now`);
126
+ }
127
+ if (options.defer && strategy !== null && strategy.kind !== "eager") {
128
+ options.defer(island, el, mountLate);
129
+ continue;
51
130
  }
131
+ if (typeof entry !== "function") {
132
+ mountLate();
133
+ continue;
134
+ }
135
+ const c = build(entry);
136
+ if (c !== undefined) out.push(c);
52
137
  }
53
138
  return out;
54
139
  }
140
+
141
+ function message(err: unknown): string {
142
+ return err instanceof Error ? err.message : String(err);
143
+ }
package/src/page/spec.ts CHANGED
@@ -15,6 +15,12 @@ export interface IslandSpec {
15
15
  presentation?: string;
16
16
  /** Handed to the factory as is. Always an object. */
17
17
  config: Record<string, unknown>;
18
+ /**
19
+ * When it mounts: `eager` (also when absent), `idle`, `visible` or
20
+ * `media:<query>` (see parseLoad). IslandPage waits for it; mountIslands
21
+ * does when given a `defer`.
22
+ */
23
+ load?: string;
18
24
  }
19
25
 
20
26
  export interface PageSpec {
@@ -61,6 +67,7 @@ export function readSpec<Ext extends object>(text: string | null | undefined, ex
61
67
  slot: is.slot,
62
68
  ...(typeof is.presentation === "string" && { presentation: is.presentation }),
63
69
  config: isObject(is.config) ? is.config : {},
70
+ ...(typeof is.load === "string" && { load: is.load }),
64
71
  });
65
72
  }
66
73
  const spec: PageSpec = { layout: raw.layout, islands };