mount-observer-script-element 0.0.1

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/.hintrc ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "extends": [
3
+ "development"
4
+ ],
5
+ "hints": {
6
+ "compat-api/html": [
7
+ "default",
8
+ {
9
+ "ignore": [
10
+ "template[shadowrootmode]"
11
+ ]
12
+ }
13
+ ]
14
+ }
15
+ }
@@ -0,0 +1,251 @@
1
+ # Technology Stack
2
+
3
+ ## Language & Build System
4
+
5
+ - **Primary Language**: TypeScript (compiled to JavaScript ES modules)
6
+ - **Target**: ESNext with strict mode enabled
7
+ - **Module System**: ES Modules (ESM)
8
+ - **No Build Tool**: TypeScript compiler only, no bundler (Webpack, Rollup, etc.)
9
+
10
+ ## TypeScript Configuration
11
+
12
+ - Strict mode enabled
13
+ - Experimental decorators: false
14
+ - Source maps: disabled
15
+ - Line endings: LF (Unix-style)
16
+ - Skip lib check: true
17
+
18
+ ## Testing
19
+
20
+ - **Test Framework**: Playwright (browser automation testing)
21
+ - **Test Files**: Located in `tests/` directory with `.spec.mjs` extension
22
+ - **HTML Test Files**: Corresponding `.html` files for each test spec
23
+
24
+ ## Dependencies
25
+
26
+ - **Runtime**: None (zero runtime dependencies)
27
+ - **Dev Dependencies**:
28
+ - `@playwright/test` - Browser testing
29
+ - `spa-ssi` - Development server
30
+
31
+ ## Common Commands
32
+
33
+ ```bash
34
+ # Install dependencies
35
+ npm ci
36
+
37
+ # Run tests
38
+ npm test
39
+
40
+ # Start development server
41
+ npm run serve
42
+
43
+ # Run Safari browser tests
44
+ npm run safari
45
+
46
+ # Update dependencies
47
+ npm run update
48
+ ```
49
+
50
+ ## Compilation
51
+
52
+ TypeScript files are compiled to JavaScript using `tsc`. Both `.ts` and `.js` files are committed to the repository. The JavaScript files are the actual runtime artifacts.
53
+
54
+ **CRITICAL**: Always compile TypeScript using the configuration in `tsconfig.json`:
55
+ ```bash
56
+ tsc
57
+ ```
58
+
59
+ Do NOT use `tsc` with individual file arguments or custom flags. The `tsconfig.json` contains the correct compiler settings for the entire project.
60
+
61
+ **Legacy Folder**: The `legacy/` folder contains old code that is not maintained. Do not expect files in the `legacy/` folder to compile or pass tests. Focus only on the root-level code and tests.
62
+
63
+ ## Type Definition Files
64
+
65
+ **Type-Only Files**: Files containing only TypeScript type definitions should use the `.d.ts` extension and must not generate a `.js` file when compiled.
66
+
67
+ **Key Rules**:
68
+ - Type definition files must end with `.d.ts` (e.g., `types.d.ts`)
69
+ - `.d.ts` files should contain only types, interfaces, and type aliases
70
+ - Never include runtime values (constants, functions, classes) in `.d.ts` files
71
+ - Constants and runtime values belong in separate `.ts` files that compile to `.js`
72
+
73
+ **Pattern**:
74
+ ```typescript
75
+ // types.d.ts - Type definitions only
76
+ export interface MountInit {
77
+ whereElementMatches: string;
78
+ }
79
+ export type mountEventName = 'mount';
80
+
81
+ // constants.ts - Runtime values
82
+ export const mountEventName = 'mount';
83
+ export const dismountEventName = 'dismount';
84
+ ```
85
+
86
+ **Why this matters**:
87
+ - Prevents unnecessary `.js` files from being generated for type-only code
88
+ - Keeps type definitions separate from runtime code
89
+ - Follows TypeScript best practices for library distribution
90
+ - Reduces bundle size by excluding type-only code from runtime
91
+
92
+ **When to apply**:
93
+ - Creating files that only contain TypeScript types, interfaces, or type aliases
94
+ - Separating type definitions from implementation
95
+ - Defining public API types for library consumers
96
+
97
+ ## Custom Event Classes
98
+
99
+ **Event Classes over CustomEvent**: When dispatching events, define custom classes that extend the Event class rather than using CustomEvent with detail objects.
100
+
101
+ **Key Rules**:
102
+ - Create dedicated event classes that extend Event
103
+ - Define event properties as public class members
104
+ - Include a static eventName property for the event type string
105
+ - Export corresponding interfaces for type safety
106
+
107
+ **Pattern**:
108
+ ```typescript
109
+ // Events.ts - Event class definitions
110
+ export class MountEvent extends Event implements IMountEvent {
111
+ static eventName: mountEventName = 'mount';
112
+
113
+ constructor(public mountedElement: Element, public modules: any[]) {
114
+ super(MountEvent.eventName);
115
+ }
116
+ }
117
+
118
+ // Usage in code
119
+ this.dispatchEvent(new MountEvent(element, modules));
120
+
121
+ // Listening with proper typing
122
+ observer.addEventListener('mount', (e: MountEvent) => {
123
+ console.log(e.mountedElement, e.modules);
124
+ });
125
+ ```
126
+
127
+ **Why this matters**:
128
+ - CustomEvent is a legacy approach that uses untyped detail objects
129
+ - Custom event classes provide better type safety and IDE autocomplete
130
+ - Properties are directly accessible without going through event.detail
131
+ - Follows modern JavaScript/TypeScript best practices
132
+ - Makes the API more discoverable and self-documenting
133
+
134
+ **When to apply**:
135
+ - All event dispatching in the library
136
+ - When defining public event APIs
137
+ - When you need strongly-typed event data
138
+
139
+ ## Code Splitting Principle
140
+
141
+ **Conditional Code Loading**: If a significant block of code (>6 lines) only executes based on optional configuration settings, extract it to a separate module and load it dynamically using `import()`.
142
+
143
+ **Benefits**:
144
+ - Reduces initial bundle size for users who don't need the feature
145
+ - Improves tree-shaking effectiveness
146
+ - Keeps core modules lean and focused
147
+
148
+ **Example**:
149
+ ```typescript
150
+ // Instead of including all import logic in MountObserver
151
+ async #loadImports(): Promise<void> {
152
+ // Dynamically load only when MountInit.import is specified
153
+ const { loadImports } = await import('./loadImports.js');
154
+ this.#modules = await loadImports(this.#init.import);
155
+ }
156
+ ```
157
+
158
+ **When to apply**:
159
+ - Feature-specific utilities (e.g., import loading, intersection observers)
160
+ - Complex conditional logic blocks
161
+ - Optional API surface areas
162
+ - Heavy dependencies used conditionally
163
+
164
+ ## Memory Management
165
+
166
+ **WeakRef for DOM Nodes**: Store references to DOM nodes (especially observed root nodes) as `WeakRef<Node>` to prevent memory leaks when nodes are removed from the document.
167
+
168
+ **Why this matters**:
169
+ - If a MountObserver instance outlives the observed DOM subtree, a strong reference would prevent garbage collection
170
+ - Shadow roots and detached fragments are particularly vulnerable
171
+ - WeakRef allows the DOM to be GC'd even if the observer is still referenced
172
+
173
+ **Pattern**:
174
+ ```typescript
175
+ class MountObserver {
176
+ #rootNode: WeakRef<Node> | undefined;
177
+
178
+ observe(rootNode: Node): void {
179
+ this.#rootNode = new WeakRef(rootNode);
180
+ // Use rootNode directly here while it's in scope
181
+ }
182
+
183
+ someMethod(): void {
184
+ const rootNode = this.#rootNode?.deref();
185
+ if (!rootNode) {
186
+ // Node was garbage collected, handle gracefully
187
+ return;
188
+ }
189
+ // Use rootNode
190
+ }
191
+ }
192
+ ```
193
+
194
+ **When to apply**:
195
+ - Storing references to observed DOM nodes
196
+ - Caching DOM elements that might be removed
197
+ - Any long-lived object holding DOM references
198
+
199
+ ## Package Exports
200
+
201
+ The package uses conditional exports in package.json, providing both default (JS) and types (TS) for each module. Main entry point is `MountObserver.js`.
202
+
203
+ ## Bare Specifier Imports & Import Maps
204
+
205
+ **Import Pattern for Node Dependencies**: This package uses bare specifiers with explicit `.js` extensions when importing from node_modules dependencies.
206
+
207
+ **Example**:
208
+ ```typescript
209
+ import { assignGingerly } from 'assign-gingerly/assignGingerly.js';
210
+ ```
211
+
212
+ **Key Rules**:
213
+ - Always include the `.js` extension in bare specifier imports
214
+ - Use the full path including the file (e.g., `/index.js`)
215
+ - This works natively in browsers via import maps
216
+
217
+ **Import Map Setup**: The project uses server-side includes (SSI) to inject import maps into HTML files during development.
218
+
219
+ **Pattern**:
220
+ ```html
221
+ <!-- In demo/test HTML files -->
222
+ <!-- #include virtual="/imports.html" -->
223
+ ```
224
+
225
+ **What this does**:
226
+ - The `spa-ssi` development server (configured in `package.json` scripts) processes SSI directives
227
+ - The `imports.html` file at the project root contains the import map
228
+ - The import map maps bare specifiers to `/node_modules/` paths
229
+ - This enables native browser support for bare imports without bundling
230
+
231
+ **Import Map Structure** (`imports.html`):
232
+ ```html
233
+ <script type=importmap>
234
+ {
235
+ "imports": {
236
+ "assign-gingerly/": "/node_modules/assign-gingerly/"
237
+ }
238
+ }
239
+ </script>
240
+ ```
241
+
242
+ **Benefits**:
243
+ - No build step required for development
244
+ - Native ES modules work directly in the browser
245
+ - Dependencies resolve naturally via import maps
246
+ - Matches production CDN patterns (e.g., unpkg, esm.sh)
247
+
248
+ **When to apply**:
249
+ - All imports from node_modules dependencies
250
+ - Demo and test HTML files need the SSI include directive
251
+ - Import map must be updated when adding new dependencies
@@ -0,0 +1,2 @@
1
+ {
2
+ }
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bruce B. Anderson
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/MOSE.js ADDED
@@ -0,0 +1,189 @@
1
+ import { getHighestCERNode } from './getHighestCERNode.js';
2
+ import { MountObserver } from 'mount-observer/MountObserver.js';
3
+ import { mountEventName } from 'mount-observer/Events.js';
4
+ /**
5
+ * Symbol to track if MountObserver has been set up for an element
6
+ */
7
+ const MOUNT_OBSERVER_SETUP = Symbol.for('cteH9dMG-UWwxVaMwFgvQA');
8
+ /**
9
+ * MOSE (Mount Observer Script Element) Mixin
10
+ *
11
+ * This mixin adds functionality to:
12
+ * 1. Check for duplicate custom element registrations within the same custom element registry scope
13
+ * 2. Monitor for <script type="mountobserver"> elements and apply their configurations
14
+ *
15
+ * @param Base - The base class to extend
16
+ * @returns Extended class with MOSE functionality
17
+ */
18
+ export function MOSE(Base) {
19
+ return class extends Base {
20
+ #mountObserver;
21
+ constructor(...args) {
22
+ super(...args);
23
+ this.#checkForDuplicateRegistration();
24
+ this.#setupMountObserver();
25
+ }
26
+ #checkForDuplicateRegistration() {
27
+ // Get the tag name of this element
28
+ // const tagName = this.tagName.toLowerCase();
29
+ const { localName } = this;
30
+ // Find the highest node with the same custom element registry
31
+ const highestCERNode = getHighestCERNode(this);
32
+ if (!highestCERNode) {
33
+ return;
34
+ }
35
+ // Get the custom element registry for this scope
36
+ // const registry = (highestCERNode as any).customElementRegistry as CustomElementRegistry | undefined;
37
+ // // If there's no custom registry, use the global one
38
+ // const targetRegistry = registry || customElements;
39
+ // Check if an element with this name is already defined in this registry
40
+ try {
41
+ const existingTags = Array.from(highestCERNode.querySelectorAll(localName)).filter(x => x !== this);
42
+ if (existingTags.length > 0) {
43
+ throw new Error(`Custom element "${localName}" is already defined in this custom element registry scope.`);
44
+ }
45
+ }
46
+ catch (error) {
47
+ // If get() throws, it means the element isn't registered yet, which is fine
48
+ // But if it's our error, re-throw it
49
+ if (error instanceof Error && error.message.includes('already defined')) {
50
+ throw error;
51
+ }
52
+ }
53
+ // Copy mountobserver script elements from container
54
+ this.#getContainerMOSEs(highestCERNode);
55
+ }
56
+ #getContainerMOSEs(highestCERNode) {
57
+ // Step 1: Don't do anything if highestCERNode is the document root
58
+ if (highestCERNode === document) {
59
+ return;
60
+ }
61
+ // Step 2: Get parent element or host
62
+ let parentNode = null;
63
+ if (highestCERNode instanceof Element) {
64
+ parentNode = highestCERNode.parentElement;
65
+ }
66
+ else if (highestCERNode instanceof ShadowRoot) {
67
+ parentNode = highestCERNode.host;
68
+ }
69
+ if (!parentNode) {
70
+ return;
71
+ }
72
+ // Step 3: Find the highestCERNode of the parent
73
+ const parentHighestCERNode = getHighestCERNode(parentNode);
74
+ // Find parent custom element with same localName
75
+ if (!parentHighestCERNode || !('querySelector' in parentHighestCERNode)) {
76
+ return;
77
+ }
78
+ const parentCE = parentHighestCERNode.querySelector(this.localName);
79
+ if (!parentCE) {
80
+ return;
81
+ }
82
+ // If highestCERNode contains parentCE, exit
83
+ if (highestCERNode.contains?.(parentCE)) {
84
+ return;
85
+ }
86
+ // Clone and append script elements
87
+ this.#cloneAndAppendScripts(parentCE);
88
+ // Add event listener for future mount events
89
+ parentCE.addEventListener(mountEventName, (e) => {
90
+ const mountedElement = e.mountedElement;
91
+ if (mountedElement instanceof HTMLScriptElement && mountedElement.type === 'mountobserver') {
92
+ this.#cloneAndAppendScripts(parentCE);
93
+ }
94
+ });
95
+ }
96
+ #cloneAndAppendScripts(sourceElement) {
97
+ const scripts = Array.from(sourceElement.querySelectorAll('script[type="mountobserver"]'));
98
+ // Get exclude value from property or attribute
99
+ const exclude = this.exclude ?? this.getAttribute('exclude');
100
+ for (const script of scripts) {
101
+ // Check if script matches exclude criteria
102
+ if (exclude && script.matches(exclude)) {
103
+ continue;
104
+ }
105
+ const src = script.getAttribute('src');
106
+ // Check if we already have a script with the same src
107
+ const existingScripts = Array.from(this.querySelectorAll('script[type="mountobserver"]'));
108
+ const alreadyExists = existingScripts.some(existing => {
109
+ const existingSrc = existing.getAttribute('src');
110
+ return existingSrc === src;
111
+ });
112
+ if (!alreadyExists) {
113
+ const clonedScript = script.cloneNode(true);
114
+ this.appendChild(clonedScript);
115
+ }
116
+ }
117
+ }
118
+ async #setupMountObserver() {
119
+ // Find the highest node with the same custom element registry
120
+ const highestCERNode = getHighestCERNode(this);
121
+ if (!highestCERNode) {
122
+ return;
123
+ }
124
+ // Check if MountObserver has already been set up for this element
125
+ const existingObserver = highestCERNode[MOUNT_OBSERVER_SETUP];
126
+ if (existingObserver) {
127
+ // Subscribe to the existing observer's mount event and re-dispatch from this element
128
+ existingObserver.addEventListener(mountEventName, (e) => {
129
+ const { mountedElement } = e;
130
+ if (this.contains(mountedElement)) {
131
+ this.dispatchEvent(e);
132
+ }
133
+ });
134
+ return;
135
+ }
136
+ // Set up MountObserver to watch for <script type="mountobserver"> elements
137
+ this.#mountObserver = new MountObserver({
138
+ whereElementMatches: 'script[type="mountobserver"]',
139
+ do: async (scriptElement) => {
140
+ await this.#processScriptElement(scriptElement, highestCERNode);
141
+ }
142
+ });
143
+ // Mark that we've set up the MountObserver for this element
144
+ highestCERNode[MOUNT_OBSERVER_SETUP] = this.#mountObserver;
145
+ await this.#mountObserver.observe(highestCERNode);
146
+ }
147
+ async #processScriptElement(scriptElement, rootNode) {
148
+ let config = {};
149
+ // Step 1: Check if script has src attribute and load JSON
150
+ const src = scriptElement.getAttribute('src');
151
+ if (src) {
152
+ try {
153
+ const response = await import(src, { with: { type: 'json' } });
154
+ config = structuredClone(response.default);
155
+ }
156
+ catch (error) {
157
+ console.error(`Failed to load JSON from ${scriptElement.src}:`, error);
158
+ return;
159
+ }
160
+ }
161
+ // Step 2: If innerHTML is non-trivial, parse and merge it
162
+ const innerHTML = scriptElement.innerHTML.trim();
163
+ if (innerHTML) {
164
+ try {
165
+ const parsedJSON = JSON.parse(innerHTML);
166
+ // Step 3: Import assignGingerly and merge
167
+ const { assignGingerly } = await import('assign-gingerly/assignGingerly.js');
168
+ config = assignGingerly(config, parsedJSON, {
169
+ registry: scriptElement.customElementRegistry.assignGingerlyRegistry
170
+ });
171
+ }
172
+ catch (error) {
173
+ console.error('Failed to parse script innerHTML as JSON:', error);
174
+ return;
175
+ }
176
+ }
177
+ // Step 4: Apply MountObserver with the merged config
178
+ if (Object.keys(config).length > 0) {
179
+ try {
180
+ const observer = new MountObserver(config);
181
+ observer.observe(rootNode);
182
+ }
183
+ catch (error) {
184
+ console.error('Failed to create MountObserver with config:', error);
185
+ }
186
+ }
187
+ }
188
+ };
189
+ }