@nifrajs/island-trigger 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,25 @@
1
+ # @nifrajs/island-trigger
2
+
3
+ Framework-neutral, browser-side trigger scheduling shared by Nifra’s two island runtimes:
4
+ `@nifrajs/web/islands` and `@nifrajs/islets`.
5
+
6
+ It has no DOM or UI-framework dependency. The public contract is the small `scheduleTrigger`
7
+ primitive with these strategies:
8
+
9
+ ```ts
10
+ import { scheduleTrigger } from "@nifrajs/island-trigger"
11
+
12
+ const dispose = scheduleTrigger({ media: "(min-width: 768px)" }, () => {
13
+ // Activate the island.
14
+ })
15
+
16
+ dispose() // cancel before the trigger fires
17
+ ```
18
+
19
+ `load`, `idle`, `visible`, and media-query triggers are one-shot. Idle and visibility strategies
20
+ fall back safely when the browser capability is unavailable; malformed or oversized media queries
21
+ remain inert. The package only schedules the callback - each island runtime owns its own markers,
22
+ registry, signals, and framework integration.
23
+
24
+ For AI agents, see [`LLM.md`](./LLM.md). The wider reference is in
25
+ [`llms-full.txt`](../../llms-full.txt).
@@ -0,0 +1,22 @@
1
+ /**
2
+ * @nifrajs/island-trigger - the tiny browser-side trigger primitive shared by Nifra's two island
3
+ * runtimes. It knows scheduling and browser capability fallbacks, but it knows nothing about markers,
4
+ * registries, signals, or UI frameworks.
5
+ */
6
+ /** When an island's enhancer runs. An object strategy is intentionally limited to media queries. */
7
+ export type IslandStrategy = "load" | "idle" | "visible" | {
8
+ readonly media: string;
9
+ };
10
+ /** The maximum media-query length accepted from an HTML data attribute. */
11
+ export declare const MAX_MEDIA_QUERY_LENGTH = 256;
12
+ export interface TriggerOptions {
13
+ /** Target used by the `visible` strategy. Missing targets degrade to immediate activation. */
14
+ readonly target?: object;
15
+ }
16
+ /**
17
+ * Schedule an island trigger and return a disposer. Every successful trigger is one-shot: a late
18
+ * media/visibility/idle event cannot run an enhancer twice. Invalid media strategies remain inert,
19
+ * which keeps malformed untrusted HTML fail-closed while leaving its server-rendered content usable.
20
+ */
21
+ export declare function scheduleTrigger(strategy: IslandStrategy, run: () => void, options?: TriggerOptions): () => void;
22
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,oGAAoG;AACpG,MAAM,MAAM,cAAc,GAAG,MAAM,GAAG,MAAM,GAAG,SAAS,GAAG;IAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAA;AAErF,2EAA2E;AAC3E,eAAO,MAAM,sBAAsB,MAAM,CAAA;AAEzC,MAAM,WAAW,cAAc;IAC7B,8FAA8F;IAC9F,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAA;CACzB;AAyCD;;;;GAIG;AACH,wBAAgB,eAAe,CAC7B,QAAQ,EAAE,cAAc,EACxB,GAAG,EAAE,MAAM,IAAI,EACf,OAAO,GAAE,cAAmB,GAC3B,MAAM,IAAI,CAwFZ"}
package/dist/index.js ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * @nifrajs/island-trigger - the tiny browser-side trigger primitive shared by Nifra's two island
3
+ * runtimes. It knows scheduling and browser capability fallbacks, but it knows nothing about markers,
4
+ * registries, signals, or UI frameworks.
5
+ */
6
+ /** The maximum media-query length accepted from an HTML data attribute. */
7
+ export const MAX_MEDIA_QUERY_LENGTH = 256;
8
+ const NOOP = () => { };
9
+ const mediaQuery = (strategy) => {
10
+ if (typeof strategy !== "object" || strategy === null)
11
+ return undefined;
12
+ const query = strategy.media;
13
+ return query.length > 0 && query.length <= MAX_MEDIA_QUERY_LENGTH ? query : undefined;
14
+ };
15
+ /**
16
+ * Schedule an island trigger and return a disposer. Every successful trigger is one-shot: a late
17
+ * media/visibility/idle event cannot run an enhancer twice. Invalid media strategies remain inert,
18
+ * which keeps malformed untrusted HTML fail-closed while leaving its server-rendered content usable.
19
+ */
20
+ export function scheduleTrigger(strategy, run, options = {}) {
21
+ // Cast through unknown so a consumer's DOM lib cannot merge its stricter Element signatures into
22
+ // this structural, DOM-free contract.
23
+ const browser = globalThis;
24
+ let fired = false;
25
+ let disposed = false;
26
+ let cleanup;
27
+ const fire = () => {
28
+ if (fired || disposed)
29
+ return;
30
+ fired = true;
31
+ cleanup?.();
32
+ cleanup = undefined;
33
+ run();
34
+ };
35
+ if (strategy === "load") {
36
+ fire();
37
+ return NOOP;
38
+ }
39
+ if (strategy === "idle") {
40
+ const ric = browser.requestIdleCallback;
41
+ if (ric === undefined) {
42
+ const handle = globalThis.setTimeout(fire, 1);
43
+ return () => {
44
+ disposed = true;
45
+ globalThis.clearTimeout(handle);
46
+ };
47
+ }
48
+ const handle = ric(fire);
49
+ return () => {
50
+ disposed = true;
51
+ browser.cancelIdleCallback?.(handle);
52
+ };
53
+ }
54
+ if (strategy === "visible") {
55
+ const Observer = browser.IntersectionObserver;
56
+ if (options.target === undefined || Observer === undefined) {
57
+ fire();
58
+ return NOOP;
59
+ }
60
+ const observer = new Observer((entries) => {
61
+ if (entries.some((entry) => entry.isIntersecting))
62
+ fire();
63
+ });
64
+ observer.observe(options.target);
65
+ cleanup = () => observer.disconnect();
66
+ return () => {
67
+ disposed = true;
68
+ cleanup?.();
69
+ cleanup = undefined;
70
+ };
71
+ }
72
+ const query = mediaQuery(strategy);
73
+ const matchMedia = browser.matchMedia;
74
+ if (query === undefined)
75
+ return NOOP;
76
+ if (matchMedia === undefined) {
77
+ // No matchMedia means the browser cannot honor a conditional trigger. Immediate activation is
78
+ // the availability fallback; malformed/oversized queries remain inert above.
79
+ fire();
80
+ return NOOP;
81
+ }
82
+ const list = matchMedia(query);
83
+ if (list.matches) {
84
+ fire();
85
+ return NOOP;
86
+ }
87
+ const onChange = (event) => {
88
+ if (event.matches)
89
+ fire();
90
+ };
91
+ if (list.addEventListener !== undefined) {
92
+ list.addEventListener("change", onChange);
93
+ cleanup = () => list.removeEventListener?.("change", onChange);
94
+ }
95
+ else if (list.addListener !== undefined) {
96
+ list.addListener(onChange);
97
+ cleanup = () => list.removeListener?.(onChange);
98
+ }
99
+ else {
100
+ return NOOP;
101
+ }
102
+ return () => {
103
+ disposed = true;
104
+ cleanup?.();
105
+ cleanup = undefined;
106
+ };
107
+ }
108
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@nifrajs/island-trigger",
3
+ "version": "3.1.0",
4
+ "description": "Tiny, framework-neutral island trigger scheduler for nifra.",
5
+ "keywords": [
6
+ "nifra",
7
+ "islands",
8
+ "triggers",
9
+ "browser"
10
+ ],
11
+ "type": "module",
12
+ "main": "./dist/index.js",
13
+ "module": "./dist/index.js",
14
+ "types": "./src/index.ts",
15
+ "exports": {
16
+ ".": {
17
+ "bun": "./src/index.ts",
18
+ "types": "./dist/index.d.ts",
19
+ "default": "./src/index.ts"
20
+ }
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "src"
25
+ ],
26
+ "sideEffects": false,
27
+ "scripts": {
28
+ "build": "tsc -p tsconfig.build.json && bun run ../../scripts/fix-dts.ts dist",
29
+ "test": "bun test",
30
+ "typecheck": "tsc --noEmit"
31
+ },
32
+ "license": "MIT",
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/nifrajs/nifra.git",
39
+ "directory": "packages/island-trigger"
40
+ },
41
+ "bugs": {
42
+ "url": "https://github.com/nifrajs/nifra/issues"
43
+ },
44
+ "homepage": "https://github.com/nifrajs/nifra#readme"
45
+ }
package/src/index.ts ADDED
@@ -0,0 +1,154 @@
1
+ /**
2
+ * @nifrajs/island-trigger - the tiny browser-side trigger primitive shared by Nifra's two island
3
+ * runtimes. It knows scheduling and browser capability fallbacks, but it knows nothing about markers,
4
+ * registries, signals, or UI frameworks.
5
+ */
6
+
7
+ /** When an island's enhancer runs. An object strategy is intentionally limited to media queries. */
8
+ export type IslandStrategy = "load" | "idle" | "visible" | { readonly media: string }
9
+
10
+ /** The maximum media-query length accepted from an HTML data attribute. */
11
+ export const MAX_MEDIA_QUERY_LENGTH = 256
12
+
13
+ export interface TriggerOptions {
14
+ /** Target used by the `visible` strategy. Missing targets degrade to immediate activation. */
15
+ readonly target?: object
16
+ }
17
+
18
+ const NOOP = (): void => {}
19
+
20
+ /** Structural event shape used instead of a DOM lib type so this package stays runtime-neutral. */
21
+ interface MediaQueryChangeEvent {
22
+ readonly matches: boolean
23
+ }
24
+
25
+ interface IntersectionEntryLike {
26
+ readonly isIntersecting: boolean
27
+ }
28
+
29
+ interface IntersectionObserverLike {
30
+ observe(target: object): void
31
+ disconnect(): void
32
+ }
33
+
34
+ interface BrowserCapabilities {
35
+ readonly requestIdleCallback?: (callback: () => void) => number
36
+ readonly cancelIdleCallback?: (handle: number) => void
37
+ readonly matchMedia?: (query: string) => MediaQueryListLike
38
+ readonly IntersectionObserver?: new (
39
+ callback: (entries: readonly IntersectionEntryLike[]) => void,
40
+ ) => IntersectionObserverLike
41
+ }
42
+
43
+ type MediaQueryListLike = {
44
+ readonly matches: boolean
45
+ addEventListener?: (type: "change", listener: (event: MediaQueryChangeEvent) => void) => void
46
+ removeEventListener?: (type: "change", listener: (event: MediaQueryChangeEvent) => void) => void
47
+ addListener?: (listener: (event: MediaQueryChangeEvent) => void) => void
48
+ removeListener?: (listener: (event: MediaQueryChangeEvent) => void) => void
49
+ }
50
+
51
+ const mediaQuery = (strategy: IslandStrategy): string | undefined => {
52
+ if (typeof strategy !== "object" || strategy === null) return undefined
53
+ const query = strategy.media
54
+ return query.length > 0 && query.length <= MAX_MEDIA_QUERY_LENGTH ? query : undefined
55
+ }
56
+
57
+ /**
58
+ * Schedule an island trigger and return a disposer. Every successful trigger is one-shot: a late
59
+ * media/visibility/idle event cannot run an enhancer twice. Invalid media strategies remain inert,
60
+ * which keeps malformed untrusted HTML fail-closed while leaving its server-rendered content usable.
61
+ */
62
+ export function scheduleTrigger(
63
+ strategy: IslandStrategy,
64
+ run: () => void,
65
+ options: TriggerOptions = {},
66
+ ): () => void {
67
+ // Cast through unknown so a consumer's DOM lib cannot merge its stricter Element signatures into
68
+ // this structural, DOM-free contract.
69
+ const browser = globalThis as unknown as BrowserCapabilities
70
+ let fired = false
71
+ let disposed = false
72
+ let cleanup: (() => void) | undefined
73
+
74
+ const fire = (): void => {
75
+ if (fired || disposed) return
76
+ fired = true
77
+ cleanup?.()
78
+ cleanup = undefined
79
+ run()
80
+ }
81
+
82
+ if (strategy === "load") {
83
+ fire()
84
+ return NOOP
85
+ }
86
+
87
+ if (strategy === "idle") {
88
+ const ric = browser.requestIdleCallback
89
+ if (ric === undefined) {
90
+ const handle = globalThis.setTimeout(fire, 1)
91
+ return () => {
92
+ disposed = true
93
+ globalThis.clearTimeout(handle)
94
+ }
95
+ }
96
+ const handle = ric(fire)
97
+ return () => {
98
+ disposed = true
99
+ browser.cancelIdleCallback?.(handle)
100
+ }
101
+ }
102
+
103
+ if (strategy === "visible") {
104
+ const Observer = browser.IntersectionObserver
105
+ if (options.target === undefined || Observer === undefined) {
106
+ fire()
107
+ return NOOP
108
+ }
109
+ const observer = new Observer((entries) => {
110
+ if (entries.some((entry) => entry.isIntersecting)) fire()
111
+ })
112
+ observer.observe(options.target)
113
+ cleanup = () => observer.disconnect()
114
+ return () => {
115
+ disposed = true
116
+ cleanup?.()
117
+ cleanup = undefined
118
+ }
119
+ }
120
+
121
+ const query = mediaQuery(strategy)
122
+ const matchMedia = browser.matchMedia
123
+ if (query === undefined) return NOOP
124
+ if (matchMedia === undefined) {
125
+ // No matchMedia means the browser cannot honor a conditional trigger. Immediate activation is
126
+ // the availability fallback; malformed/oversized queries remain inert above.
127
+ fire()
128
+ return NOOP
129
+ }
130
+
131
+ const list = matchMedia(query)
132
+ if (list.matches) {
133
+ fire()
134
+ return NOOP
135
+ }
136
+
137
+ const onChange = (event: MediaQueryChangeEvent): void => {
138
+ if (event.matches) fire()
139
+ }
140
+ if (list.addEventListener !== undefined) {
141
+ list.addEventListener("change", onChange)
142
+ cleanup = () => list.removeEventListener?.("change", onChange)
143
+ } else if (list.addListener !== undefined) {
144
+ list.addListener(onChange)
145
+ cleanup = () => list.removeListener?.(onChange)
146
+ } else {
147
+ return NOOP
148
+ }
149
+ return () => {
150
+ disposed = true
151
+ cleanup?.()
152
+ cleanup = undefined
153
+ }
154
+ }