pulse-js-framework 1.7.15 → 1.7.17

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.
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Pulse SSR Async Context - Async operation collection for SSR
3
+ *
4
+ * Collects and manages async operations during server-side rendering.
5
+ * Enables data prefetching before HTML generation.
6
+ */
7
+
8
+ // ============================================================================
9
+ // SSR Async Context
10
+ // ============================================================================
11
+
12
+ /**
13
+ * Context for collecting async operations during SSR.
14
+ * Tracks pending promises and caches resolved data for re-renders.
15
+ */
16
+ export class SSRAsyncContext {
17
+ constructor() {
18
+ /** @type {Array<{key: any, promise: Promise}>} */
19
+ this.pending = [];
20
+
21
+ /** @type {Map<any, any>} */
22
+ this.resolved = new Map();
23
+
24
+ /** @type {Map<any, Error>} */
25
+ this.errors = new Map();
26
+
27
+ /** @type {boolean} */
28
+ this.collecting = true;
29
+ }
30
+
31
+ /**
32
+ * Register an async operation for collection.
33
+ * @param {any} key - Unique key for this operation (usually the async function)
34
+ * @param {Promise} promise - The promise to track
35
+ */
36
+ register(key, promise) {
37
+ if (!this.collecting) return;
38
+
39
+ // Wrap promise to capture result
40
+ const tracked = promise
41
+ .then(data => {
42
+ this.resolved.set(key, data);
43
+ return data;
44
+ })
45
+ .catch(error => {
46
+ this.errors.set(key, error);
47
+ throw error;
48
+ });
49
+
50
+ this.pending.push({ key, promise: tracked });
51
+ }
52
+
53
+ /**
54
+ * Check if a result is already cached.
55
+ * @param {any} key - Operation key
56
+ * @returns {boolean} True if result is cached
57
+ */
58
+ has(key) {
59
+ return this.resolved.has(key);
60
+ }
61
+
62
+ /**
63
+ * Get cached result for a key.
64
+ * @param {any} key - Operation key
65
+ * @returns {any} Cached result or undefined
66
+ */
67
+ get(key) {
68
+ return this.resolved.get(key);
69
+ }
70
+
71
+ /**
72
+ * Get error for a key (if the operation failed).
73
+ * @param {any} key - Operation key
74
+ * @returns {Error|undefined} Error or undefined
75
+ */
76
+ getError(key) {
77
+ return this.errors.get(key);
78
+ }
79
+
80
+ /**
81
+ * Wait for all pending async operations to complete.
82
+ * @param {number} [timeout=5000] - Maximum wait time in ms
83
+ * @returns {Promise<void>}
84
+ * @throws {Error} If timeout is exceeded
85
+ */
86
+ async waitAll(timeout = 5000) {
87
+ if (this.pending.length === 0) return;
88
+
89
+ const timeoutPromise = new Promise((_, reject) => {
90
+ setTimeout(() => {
91
+ reject(new Error(`[Pulse SSR] Async operations timed out after ${timeout}ms`));
92
+ }, timeout);
93
+ });
94
+
95
+ // Wait for all promises, catching individual errors
96
+ const allSettled = Promise.all(
97
+ this.pending.map(p => p.promise.catch(() => null))
98
+ );
99
+
100
+ await Promise.race([allSettled, timeoutPromise]);
101
+
102
+ // Stop collecting after wait
103
+ this.collecting = false;
104
+ }
105
+
106
+ /**
107
+ * Get the number of pending operations.
108
+ * @returns {number}
109
+ */
110
+ get pendingCount() {
111
+ return this.pending.length;
112
+ }
113
+
114
+ /**
115
+ * Get the number of resolved operations.
116
+ * @returns {number}
117
+ */
118
+ get resolvedCount() {
119
+ return this.resolved.size;
120
+ }
121
+
122
+ /**
123
+ * Get the number of failed operations.
124
+ * @returns {number}
125
+ */
126
+ get errorCount() {
127
+ return this.errors.size;
128
+ }
129
+
130
+ /**
131
+ * Get all resolved data as a plain object.
132
+ * @returns {Object} Map of key → value
133
+ */
134
+ getAllResolved() {
135
+ const result = {};
136
+ for (const [key, value] of this.resolved) {
137
+ // Use string key if function, otherwise try to serialize
138
+ const keyStr = typeof key === 'function' ? key.name || 'anonymous' : String(key);
139
+ result[keyStr] = value;
140
+ }
141
+ return result;
142
+ }
143
+
144
+ /**
145
+ * Reset the context for a new render pass.
146
+ */
147
+ reset() {
148
+ this.pending = [];
149
+ this.resolved.clear();
150
+ this.errors.clear();
151
+ this.collecting = true;
152
+ }
153
+ }
154
+
155
+ // ============================================================================
156
+ // Global SSR Async Context
157
+ // ============================================================================
158
+
159
+ /** @type {SSRAsyncContext|null} */
160
+ let ssrAsyncContext = null;
161
+
162
+ /**
163
+ * Get the current SSR async context.
164
+ * Returns null if not in SSR mode.
165
+ * @returns {SSRAsyncContext|null}
166
+ */
167
+ export function getSSRAsyncContext() {
168
+ return ssrAsyncContext;
169
+ }
170
+
171
+ /**
172
+ * Set the SSR async context.
173
+ * @param {SSRAsyncContext|null} ctx - Context to set, or null to clear
174
+ */
175
+ export function setSSRAsyncContext(ctx) {
176
+ ssrAsyncContext = ctx;
177
+ }
178
+
179
+ /**
180
+ * Check if currently in SSR async collection mode.
181
+ * @returns {boolean}
182
+ */
183
+ export function isCollectingAsync() {
184
+ return ssrAsyncContext !== null && ssrAsyncContext.collecting;
185
+ }
186
+
187
+ /**
188
+ * Register an async operation in the current SSR context.
189
+ * No-op if not in SSR mode.
190
+ * @param {any} key - Unique key for this operation
191
+ * @param {Promise} promise - The promise to track
192
+ */
193
+ export function registerAsync(key, promise) {
194
+ if (ssrAsyncContext) {
195
+ ssrAsyncContext.register(key, promise);
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Get cached async result from current SSR context.
201
+ * @param {any} key - Operation key
202
+ * @returns {any} Cached result or undefined
203
+ */
204
+ export function getCachedAsync(key) {
205
+ return ssrAsyncContext?.get(key);
206
+ }
207
+
208
+ /**
209
+ * Check if an async result is cached in current SSR context.
210
+ * @param {any} key - Operation key
211
+ * @returns {boolean}
212
+ */
213
+ export function hasCachedAsync(key) {
214
+ return ssrAsyncContext?.has(key) ?? false;
215
+ }
216
+
217
+ // ============================================================================
218
+ // Exports
219
+ // ============================================================================
220
+
221
+ export default {
222
+ SSRAsyncContext,
223
+ getSSRAsyncContext,
224
+ setSSRAsyncContext,
225
+ isCollectingAsync,
226
+ registerAsync,
227
+ getCachedAsync,
228
+ hasCachedAsync
229
+ };
@@ -0,0 +1,310 @@
1
+ /**
2
+ * Pulse SSR Hydrator - Client-side hydration utilities
3
+ *
4
+ * Provides utilities for hydrating server-rendered HTML by attaching
5
+ * event listeners and reactive bindings to existing DOM elements.
6
+ */
7
+
8
+ // ============================================================================
9
+ // Hydration State
10
+ // ============================================================================
11
+
12
+ /** @type {boolean} */
13
+ let isHydrating = false;
14
+
15
+ /** @type {HydrationContext|null} */
16
+ let hydrationCtx = null;
17
+
18
+ // ============================================================================
19
+ // Hydration Context
20
+ // ============================================================================
21
+
22
+ /**
23
+ * @typedef {Object} HydrationContext
24
+ * @property {Element} root - Root container element
25
+ * @property {Node|null} cursor - Current position in DOM tree
26
+ * @property {Array<Function>} cleanups - Cleanup functions for disposal
27
+ * @property {Array<{element: Element, event: string, handler: Function}>} listeners - Attached event listeners
28
+ * @property {number} depth - Current nesting depth
29
+ * @property {boolean} mismatchWarned - Whether a mismatch warning was shown
30
+ */
31
+
32
+ /**
33
+ * Create a new hydration context for a container element.
34
+ * @param {Element} root - Root container element
35
+ * @returns {HydrationContext}
36
+ */
37
+ export function createHydrationContext(root) {
38
+ return {
39
+ root,
40
+ cursor: root.firstChild,
41
+ cleanups: [],
42
+ listeners: [],
43
+ depth: 0,
44
+ mismatchWarned: false
45
+ };
46
+ }
47
+
48
+ // ============================================================================
49
+ // Hydration Mode Control
50
+ // ============================================================================
51
+
52
+ /**
53
+ * Enable or disable hydration mode.
54
+ * @param {boolean} enabled - Whether to enable hydration mode
55
+ * @param {HydrationContext|null} ctx - Hydration context (required when enabling)
56
+ */
57
+ export function setHydrationMode(enabled, ctx = null) {
58
+ isHydrating = enabled;
59
+ hydrationCtx = enabled ? ctx : null;
60
+ }
61
+
62
+ /**
63
+ * Check if currently in hydration mode.
64
+ * @returns {boolean}
65
+ */
66
+ export function isHydratingMode() {
67
+ return isHydrating;
68
+ }
69
+
70
+ /**
71
+ * Get the current hydration context.
72
+ * @returns {HydrationContext|null}
73
+ */
74
+ export function getHydrationContext() {
75
+ return hydrationCtx;
76
+ }
77
+
78
+ // ============================================================================
79
+ // DOM Cursor Navigation
80
+ // ============================================================================
81
+
82
+ /**
83
+ * Get the current node at the cursor position.
84
+ * @param {HydrationContext} ctx - Hydration context
85
+ * @returns {Node|null}
86
+ */
87
+ export function getCurrentNode(ctx) {
88
+ return ctx.cursor;
89
+ }
90
+
91
+ /**
92
+ * Advance the cursor to the next sibling.
93
+ * @param {HydrationContext} ctx - Hydration context
94
+ */
95
+ export function advanceCursor(ctx) {
96
+ if (ctx.cursor) {
97
+ ctx.cursor = ctx.cursor.nextSibling;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Enter a child scope (for nested elements).
103
+ * @param {HydrationContext} ctx - Hydration context
104
+ * @param {Element} element - Parent element to enter
105
+ */
106
+ export function enterChild(ctx, element) {
107
+ ctx.cursor = element.firstChild;
108
+ ctx.depth++;
109
+ }
110
+
111
+ /**
112
+ * Exit a child scope and restore cursor to parent level.
113
+ * @param {HydrationContext} ctx - Hydration context
114
+ * @param {Element} element - Element we're exiting
115
+ */
116
+ export function exitChild(ctx, element) {
117
+ ctx.cursor = element.nextSibling;
118
+ ctx.depth--;
119
+ }
120
+
121
+ /**
122
+ * Skip comment nodes (used as markers).
123
+ * @param {HydrationContext} ctx - Hydration context
124
+ */
125
+ export function skipComments(ctx) {
126
+ while (ctx.cursor && ctx.cursor.nodeType === 8) {
127
+ ctx.cursor = ctx.cursor.nextSibling;
128
+ }
129
+ }
130
+
131
+ // ============================================================================
132
+ // DOM Matching
133
+ // ============================================================================
134
+
135
+ /**
136
+ * Check if an element matches expected tag and basic attributes.
137
+ * @param {Node} node - Node to check
138
+ * @param {string} expectedTag - Expected tag name (lowercase)
139
+ * @param {string} [expectedId] - Expected ID (optional)
140
+ * @param {string} [expectedClass] - Expected class (optional)
141
+ * @returns {boolean}
142
+ */
143
+ export function matchesElement(node, expectedTag, expectedId, expectedClass) {
144
+ if (!node || node.nodeType !== 1) return false;
145
+
146
+ const tag = node.tagName?.toLowerCase();
147
+ if (tag !== expectedTag) return false;
148
+
149
+ if (expectedId && node.id !== expectedId) return false;
150
+ if (expectedClass && !node.classList?.contains(expectedClass)) return false;
151
+
152
+ return true;
153
+ }
154
+
155
+ /**
156
+ * Log a hydration mismatch warning.
157
+ * @param {HydrationContext} ctx - Hydration context
158
+ * @param {string} expected - What was expected
159
+ * @param {Node|null} actual - What was found
160
+ */
161
+ export function warnMismatch(ctx, expected, actual) {
162
+ if (ctx.mismatchWarned) return;
163
+
164
+ const actualDesc = actual
165
+ ? `<${actual.tagName?.toLowerCase() || actual.nodeName}>`
166
+ : 'null';
167
+
168
+ console.warn(
169
+ `[Pulse Hydration] Mismatch at depth ${ctx.depth}: ` +
170
+ `expected ${expected}, found ${actualDesc}. ` +
171
+ `This may cause hydration errors.`
172
+ );
173
+
174
+ ctx.mismatchWarned = true;
175
+ }
176
+
177
+ // ============================================================================
178
+ // Event Listener Management
179
+ // ============================================================================
180
+
181
+ /**
182
+ * Register an event listener during hydration.
183
+ * @param {HydrationContext} ctx - Hydration context
184
+ * @param {Element} element - Target element
185
+ * @param {string} event - Event name
186
+ * @param {Function} handler - Event handler
187
+ * @param {Object} [options] - Event listener options
188
+ */
189
+ export function registerListener(ctx, element, event, handler, options) {
190
+ element.addEventListener(event, handler, options);
191
+ ctx.listeners.push({ element, event, handler, options });
192
+ }
193
+
194
+ /**
195
+ * Register a cleanup function.
196
+ * @param {HydrationContext} ctx - Hydration context
197
+ * @param {Function} cleanup - Cleanup function
198
+ */
199
+ export function registerCleanup(ctx, cleanup) {
200
+ ctx.cleanups.push(cleanup);
201
+ }
202
+
203
+ // ============================================================================
204
+ // Hydration Disposal
205
+ // ============================================================================
206
+
207
+ /**
208
+ * Dispose of all hydration resources.
209
+ * Removes event listeners and runs cleanup functions.
210
+ * @param {HydrationContext} ctx - Hydration context
211
+ */
212
+ export function disposeHydration(ctx) {
213
+ // Remove all event listeners
214
+ for (const { element, event, handler, options } of ctx.listeners) {
215
+ element.removeEventListener(event, handler, options);
216
+ }
217
+ ctx.listeners = [];
218
+
219
+ // Run cleanup functions
220
+ for (const cleanup of ctx.cleanups) {
221
+ try {
222
+ cleanup();
223
+ } catch (e) {
224
+ console.error('[Pulse Hydration] Cleanup error:', e);
225
+ }
226
+ }
227
+ ctx.cleanups = [];
228
+ }
229
+
230
+ // ============================================================================
231
+ // Hydration Helpers
232
+ // ============================================================================
233
+
234
+ /**
235
+ * Find the next element matching a tag within the current scope.
236
+ * Useful for recovering from mismatches.
237
+ * @param {HydrationContext} ctx - Hydration context
238
+ * @param {string} tag - Tag name to find
239
+ * @returns {Element|null}
240
+ */
241
+ export function findNextElement(ctx, tag) {
242
+ let node = ctx.cursor;
243
+ while (node) {
244
+ if (node.nodeType === 1 && node.tagName?.toLowerCase() === tag) {
245
+ return node;
246
+ }
247
+ node = node.nextSibling;
248
+ }
249
+ return null;
250
+ }
251
+
252
+ /**
253
+ * Count remaining elements in the current scope.
254
+ * Useful for debugging hydration issues.
255
+ * @param {HydrationContext} ctx - Hydration context
256
+ * @returns {number}
257
+ */
258
+ export function countRemaining(ctx) {
259
+ let count = 0;
260
+ let node = ctx.cursor;
261
+ while (node) {
262
+ if (node.nodeType === 1) count++;
263
+ node = node.nextSibling;
264
+ }
265
+ return count;
266
+ }
267
+
268
+ /**
269
+ * Check if hydration is complete (no more nodes to process).
270
+ * @param {HydrationContext} ctx - Hydration context
271
+ * @returns {boolean}
272
+ */
273
+ export function isHydrationComplete(ctx) {
274
+ return ctx.cursor === null && ctx.depth === 0;
275
+ }
276
+
277
+ // ============================================================================
278
+ // Exports
279
+ // ============================================================================
280
+
281
+ export default {
282
+ // Mode control
283
+ setHydrationMode,
284
+ isHydratingMode,
285
+ getHydrationContext,
286
+
287
+ // Context
288
+ createHydrationContext,
289
+
290
+ // Navigation
291
+ getCurrentNode,
292
+ advanceCursor,
293
+ enterChild,
294
+ exitChild,
295
+ skipComments,
296
+
297
+ // Matching
298
+ matchesElement,
299
+ warnMismatch,
300
+ findNextElement,
301
+
302
+ // Resources
303
+ registerListener,
304
+ registerCleanup,
305
+ disposeHydration,
306
+
307
+ // Helpers
308
+ countRemaining,
309
+ isHydrationComplete
310
+ };