browser-module-runtime 0.0.4 → 0.0.6
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 +13 -0
- package/html.d.ts +34 -2
- package/package.json +1 -1
- package/src/html/index.js +172 -61
- package/src/html/props.js +112 -0
- package/src/html/scopes.js +51 -0
- package/src/web-components/index.js +14 -2
package/README.md
CHANGED
|
@@ -64,3 +64,16 @@ await handle.dispose();
|
|
|
64
64
|
- `browser-module-runtime/cache`: compile-cache implementations
|
|
65
65
|
|
|
66
66
|
`<runtime-render>` mounts framework plugins into its own element. This new generic API is distinct from the older `solid-tag-runtime` and `<solid-render>` APIs; use those packages for applications requiring their existing behavior.
|
|
67
|
+
|
|
68
|
+
### Scoped HTML (development branch)
|
|
69
|
+
|
|
70
|
+
```js
|
|
71
|
+
import {registerHTML} from 'browser-module-runtime/html';
|
|
72
|
+
registerHTML(runtime,{scope:'app',root:document}); // No initial scan or observer
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
```html
|
|
76
|
+
<runtime-render scope="app" module="/Counter.jsx" props='{"initial":5}'></runtime-render>
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The custom element waits for the runtime and module automatically. For already parsed inert script declarations use an explicit `await html.registerExisting()`; choose `observe:true` only when discovering uncontrolled future script insertions is necessary. Use `html.appendAndRegister(script)` for controlled injection. See `docs/SCOPED_HTML.md` in the workspace. These behaviors are **not** part of previously published npm `0.0.4` without a new release.
|
package/html.d.ts
CHANGED
|
@@ -1,4 +1,36 @@
|
|
|
1
1
|
import type {Runtime} from './index.js';
|
|
2
2
|
import type {WebComponentOptions} from './web-components.js';
|
|
3
|
-
export interface HTMLController {
|
|
4
|
-
|
|
3
|
+
export interface HTMLController {
|
|
4
|
+
/** Scope name; `name` is a backwards compatible alias. */
|
|
5
|
+
readonly name:string;
|
|
6
|
+
readonly scope:string;
|
|
7
|
+
readonly root:Document|Element;
|
|
8
|
+
readonly appendTarget:Document|Element;
|
|
9
|
+
readonly runtime:Runtime;
|
|
10
|
+
defineElement(tag:string,options:Omit<WebComponentOptions,'tag'>):CustomElementConstructor;
|
|
11
|
+
/** Registers an existing inert declaration without scanning. */
|
|
12
|
+
registerScript(script:HTMLScriptElement):Promise<boolean>;
|
|
13
|
+
/** Append only (no auto-registration). */
|
|
14
|
+
appendScript(script:HTMLScriptElement,target?:Document|Element):Promise<HTMLScriptElement>;
|
|
15
|
+
/** Controlled append and register without a subtree scan. */
|
|
16
|
+
appendAndRegister(script:HTMLScriptElement,target?:Document|Element):Promise<HTMLScriptElement>;
|
|
17
|
+
/** Explicit one-time discovery of scripts under root. */
|
|
18
|
+
registerExisting():Promise<void>;
|
|
19
|
+
/** Alias for registerExisting. */
|
|
20
|
+
scan():Promise<void>;
|
|
21
|
+
setRoot(root:Document|Element,options?:{registerExisting?:boolean}):Promise<Document|Element>;
|
|
22
|
+
moveTo(root:Document|Element,options?:{registerExisting?:boolean}):Promise<Document|Element>;
|
|
23
|
+
disconnect():Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
export interface RegisterHTMLOptions {
|
|
26
|
+
scope?:string;
|
|
27
|
+
/** Backward compatible alias for scope. */
|
|
28
|
+
name?:string;
|
|
29
|
+
root?:Document|Element;
|
|
30
|
+
/** Whether an element without an explicit scope can be owned by this controller. */
|
|
31
|
+
acceptUnscoped?:boolean;
|
|
32
|
+
/** Opt-in DOM observation of newly inserted inert source declarations. Default false. */
|
|
33
|
+
observe?:boolean;
|
|
34
|
+
}
|
|
35
|
+
/** Does not scan automatically. <runtime-render> itself is an autonomous custom element. */
|
|
36
|
+
export declare function registerHTML(runtime:Runtime,options?:RegisterHTMLOptions):HTMLController;
|
package/package.json
CHANGED
package/src/html/index.js
CHANGED
|
@@ -1,74 +1,185 @@
|
|
|
1
|
-
/** HTML
|
|
1
|
+
/** Scoped HTML integration: autonomous rendering, explicit discovery, opt-in observation. */
|
|
2
2
|
import {registerWebComponent} from '../web-components/index.js';
|
|
3
|
+
import {getScopeRegistry,requestedScope,containsRoot} from './scopes.js';
|
|
4
|
+
import {parseObjectProps as parseObject} from './props.js';
|
|
3
5
|
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
function
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
catch(error){throw new Error(`Invalid prop schema in ${context}: ${error.message}`);}
|
|
6
|
+
function emit(el,type,detail){const win=el.ownerDocument.defaultView;el.dispatchEvent(new win.CustomEvent(type,{detail,bubbles:true,composed:true}));}
|
|
7
|
+
function state(el,value){el.setAttribute('data-runtime-state',value);}
|
|
8
|
+
function rootDocument(root){return root?.nodeType===9?root:root?.ownerDocument;}
|
|
9
|
+
function targetsWithin(root,selector){
|
|
10
|
+
return [...(root.matches?.(selector)?[root]:[]),...(root.querySelectorAll?.(selector)||[])];
|
|
10
11
|
}
|
|
12
|
+
function elementOptions(el){return {module:el.getAttribute('module'),export:el.getAttribute('component')||'default',props:parseObject(el.getAttribute('element-props')||el.getAttribute('props'),`${el.localName} prop schema`),shadow:el.getAttribute('shadow')||'none'};}
|
|
11
13
|
|
|
12
|
-
|
|
14
|
+
function defineAutonomousElements(doc){
|
|
15
|
+
const win=doc.defaultView;
|
|
16
|
+
if(!win?.customElements)return;
|
|
17
|
+
if(!win.customElements.get('runtime-render')){
|
|
18
|
+
class RuntimeRender extends win.HTMLElement {
|
|
19
|
+
static get observedAttributes(){return ['scope','runtime','data-runtime','module','component','props'];}
|
|
20
|
+
constructor(){super();this._owner=null;this._handle=null;this._abort=null;this._generation=0;this._unsubscribe=null;this._pending=null;this._lastProps='';this._reported=null;}
|
|
21
|
+
connectedCallback(){
|
|
22
|
+
if(!this._unsubscribe)this._unsubscribe=getScopeRegistry(doc).subscribe(()=>this._reconcile());
|
|
23
|
+
this._reconcile();
|
|
24
|
+
}
|
|
25
|
+
disconnectedCallback(){
|
|
26
|
+
const generation=++this._generation;
|
|
27
|
+
if(this._pending&&!this._handle){this._key=null;this._abort?.abort();}
|
|
28
|
+
queueMicrotask(()=>{if(!this.isConnected&&generation===this._generation){this._unsubscribe?.();this._unsubscribe=null;this._key=null;this._pending=null;this._release().catch(e=>this._report(e));state(this,'disposed');}});
|
|
29
|
+
}
|
|
30
|
+
attributeChangedCallback(name,oldValue,newValue){if(oldValue!==newValue&&this.isConnected)this._reconcile();}
|
|
31
|
+
_report(error){state(this,'error');if(this._reported!==error){this._reported=error;emit(this,'runtime-error',{error,module:this.getAttribute('module'),scope:requestedScope(this)});}}
|
|
32
|
+
async _release(){
|
|
33
|
+
this._abort?.abort();this._abort=null;
|
|
34
|
+
const handle=this._handle;this._handle=null;
|
|
35
|
+
if(handle){await handle.dispose();emit(this,'runtime-disposed',{module:this.getAttribute('module')});}
|
|
36
|
+
}
|
|
37
|
+
_reconcile(){
|
|
38
|
+
if(!this.isConnected)return;
|
|
39
|
+
const match=getScopeRegistry(doc).resolve(this);
|
|
40
|
+
const id=this.getAttribute('module')||'';
|
|
41
|
+
const exportName=this.getAttribute('component')||'default';
|
|
42
|
+
const propsText=this.getAttribute('props')||'{}';
|
|
43
|
+
const key=match.controller&&`${match.controller.id}|${id}|${exportName}`;
|
|
44
|
+
// Reconcile only on meaningful changes, not unrelated registry notifications.
|
|
45
|
+
if(match.error&&this._lastErrorMessage===match.error.message)return;
|
|
46
|
+
this._lastErrorMessage=match.error?.message||null;
|
|
47
|
+
if(!match.error&&this._key===key&&this._lastProps===propsText&&(this._handle||this._pending||!match.controller))return;
|
|
48
|
+
this._key=key;this._lastProps=propsText;
|
|
49
|
+
const generation=++this._generation;
|
|
50
|
+
this._abort?.abort();
|
|
51
|
+
this._pending=null;
|
|
52
|
+
const previous=this._handle;this._handle=null;
|
|
53
|
+
const controller=match.controller;
|
|
54
|
+
this._owner=controller;
|
|
55
|
+
if(match.error){this._report(match.error);if(previous)previous.dispose().catch(e=>this._report(e));return;}
|
|
56
|
+
if(!controller||!id){state(this,'pending');if(previous)previous.dispose().catch(e=>this._report(e));return;}
|
|
57
|
+
let props;
|
|
58
|
+
try{props=parseObject(propsText,'runtime-render props');}catch(e){this._report(e);if(previous)previous.dispose().catch(err=>this._report(err));return;}
|
|
59
|
+
this._reported=null;
|
|
60
|
+
if(previous&&this._previousKey===key){
|
|
61
|
+
// Update props in place where supported, otherwise remount.
|
|
62
|
+
if(previous.capabilities?.updateProps){
|
|
63
|
+
state(this,'mounting');
|
|
64
|
+
const task=Promise.resolve().then(()=>previous.updateProps(props)).then(async()=>{
|
|
65
|
+
if(generation!==this._generation||!this.isConnected){await previous.dispose();return;}
|
|
66
|
+
this._handle=previous;state(this,'ready');
|
|
67
|
+
}).catch(e=>this._report(e));
|
|
68
|
+
this._pending=task;task.finally(()=>{if(this._pending===task)this._pending=null;});return;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const abort=new AbortController();this._abort=abort;
|
|
72
|
+
state(this,'pending');
|
|
73
|
+
const task=(async()=>{
|
|
74
|
+
if(previous)await previous.dispose();
|
|
75
|
+
await controller.runtime.ready;
|
|
76
|
+
await controller.runtime.waitForModule(id,{signal:abort.signal});
|
|
77
|
+
if(abort.signal.aborted||generation!==this._generation||!this.isConnected)return;
|
|
78
|
+
state(this,'mounting');
|
|
79
|
+
const handle=await controller.runtime.mount({module:id,export:exportName,target:this,props});
|
|
80
|
+
if(abort.signal.aborted||generation!==this._generation||!this.isConnected){await handle.dispose();return;}
|
|
81
|
+
this._handle=handle;this._previousKey=key;state(this,'ready');emit(this,'runtime-ready',{module:id,scope:controller.scope,handle});
|
|
82
|
+
})().catch(e=>{if(!abort.signal.aborted&&generation===this._generation)this._report(e);});
|
|
83
|
+
this._pending=task;task.finally(()=>{if(this._pending===task)this._pending=null;});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
win.customElements.define('runtime-render',RuntimeRender);
|
|
87
|
+
}
|
|
88
|
+
if(!win.customElements.get('runtime-element')){
|
|
89
|
+
class RuntimeElementDefinition extends win.HTMLElement {
|
|
90
|
+
static get observedAttributes(){return ['scope','runtime','data-runtime','tag','module','component','props','element-props'];}
|
|
91
|
+
constructor(){super();this._unsubscribe=null;this._registered=false;}
|
|
92
|
+
connectedCallback(){
|
|
93
|
+
if(!this._unsubscribe)this._unsubscribe=getScopeRegistry(doc).subscribe(()=>this._register());
|
|
94
|
+
this._register();
|
|
95
|
+
}
|
|
96
|
+
disconnectedCallback(){queueMicrotask(()=>{if(!this.isConnected){this._unsubscribe?.();this._unsubscribe=null;}});}
|
|
97
|
+
attributeChangedCallback(){if(this.isConnected)this._register();}
|
|
98
|
+
_register(){
|
|
99
|
+
if(!this.isConnected||this._registered)return;
|
|
100
|
+
const match=getScopeRegistry(doc).resolve(this);
|
|
101
|
+
if(match.error){emit(this,'runtime-error',{error:match.error});return;}
|
|
102
|
+
if(!match.controller){state(this,'pending');return;}
|
|
103
|
+
if(!this.getAttribute('tag')||!this.getAttribute('module'))return;
|
|
104
|
+
try{match.controller.defineElement(this.getAttribute('tag'),elementOptions(this));this._registered=true;this.hidden=true;state(this,'ready');emit(this,'runtime-ready',{tag:this.getAttribute('tag')});}
|
|
105
|
+
catch(error){state(this,'error');emit(this,'runtime-error',{error});}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
win.customElements.define('runtime-element',RuntimeElementDefinition);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
let sequence=0;
|
|
112
|
+
/** Register a scoped HTML controller. Never scans automatically. */
|
|
113
|
+
export function registerHTML(runtime,options={}){
|
|
114
|
+
const {name,scope,root=globalThis.document,observe=false,acceptUnscoped=true}=options;
|
|
13
115
|
if(!root)throw new TypeError('registerHTML requires a DOM root');
|
|
14
|
-
const doc=root
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
let alive=true;
|
|
22
|
-
let pending=Promise.resolve();
|
|
116
|
+
const doc=rootDocument(root);
|
|
117
|
+
if(!doc)throw new TypeError('registerHTML root must belong to a document');
|
|
118
|
+
const registry=getScopeRegistry(doc);
|
|
119
|
+
defineAutonomousElements(doc);
|
|
120
|
+
const resolvedScope=scope||name||'default';
|
|
121
|
+
let alive=true,observer=null,currentRoot=root,appendTarget=root,pending=Promise.resolve();
|
|
122
|
+
const controller={id:++sequence,scope:resolvedScope,get root(){return currentRoot;},acceptUnscoped,runtime,get alive(){return alive;},defineElement};
|
|
23
123
|
function defineElement(tag,config){return registerWebComponent(runtime,{...config,tag,document:doc});}
|
|
24
|
-
function
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
};
|
|
124
|
+
function owned(el){const match=registry.resolve(el);return match.controller===controller;}
|
|
125
|
+
async function registerScript(el){
|
|
126
|
+
if(!alive||!owned(el)||el.dataset.runtimeRegistered==='true')return false;
|
|
127
|
+
const type=el.getAttribute('type');
|
|
128
|
+
if(!runtime.getSupportedTypes().includes(type))return false;
|
|
129
|
+
const id=el.getAttribute('module');if(!id)return false;
|
|
130
|
+
if(el.hasAttribute('data-src'))await runtime.loadSource(id,{type,url:el.getAttribute('data-src')});
|
|
131
|
+
else runtime.define(id,runtime.normalizeInlineSource(el.textContent,{type,id,element:el}),{type});
|
|
132
|
+
if(el.hasAttribute('custom-element')){
|
|
133
|
+
defineElement(el.getAttribute('custom-element'),elementOptions(el));
|
|
134
|
+
}
|
|
135
|
+
el.dataset.runtimeRegistered='true';
|
|
136
|
+
return true;
|
|
31
137
|
}
|
|
32
|
-
function isOwned(node) {return node.getAttribute('data-runtime')===name || node.getAttribute('runtime')===name || (!node.hasAttribute('data-runtime')&&!node.hasAttribute('runtime')&&name==='default');}
|
|
33
138
|
async function scan(){
|
|
34
139
|
if(!alive)return;
|
|
35
|
-
for(const el of
|
|
36
|
-
|
|
37
|
-
const type=el.getAttribute('type');
|
|
38
|
-
if(!runtime.getSupportedTypes().includes(type))continue;
|
|
39
|
-
const id=el.getAttribute('module');
|
|
40
|
-
if(el.hasAttribute('data-src'))await runtime.loadSource(id,{type,url:el.getAttribute('data-src')});
|
|
41
|
-
else runtime.define(id,runtime.normalizeInlineSource(el.textContent,{type,id,element:el}),{type});
|
|
42
|
-
if(el.hasAttribute('custom-element')){
|
|
43
|
-
defineElement(el.getAttribute('custom-element'),elementOptions(el));
|
|
44
|
-
}
|
|
45
|
-
el.dataset.runtimeRegistered='true';
|
|
46
|
-
}
|
|
47
|
-
for(const el of root.querySelectorAll('runtime-element[tag][module]')){
|
|
48
|
-
if(!isOwned(el)||el.dataset.runtimeRegistered==='true')continue;
|
|
49
|
-
defineElement(el.getAttribute('tag'),elementOptions(el));
|
|
50
|
-
el.dataset.runtimeRegistered='true';
|
|
51
|
-
el.hidden=true;
|
|
52
|
-
}
|
|
53
|
-
const targets=root.querySelectorAll('runtime-render');
|
|
54
|
-
for(const el of targets){
|
|
55
|
-
if(!isOwned(el)||active.has(el))continue;
|
|
56
|
-
let props={};
|
|
57
|
-
try {props=JSON.parse(el.getAttribute('props')||'{}');}catch(e){throw new Error(`Invalid props JSON in runtime-render: ${e.message}`);}
|
|
58
|
-
if(!props||typeof props!=='object'||Array.isArray(props))throw new Error('runtime-render props must be a JSON object');
|
|
59
|
-
const module=el.getAttribute('module');if(!module)continue;
|
|
60
|
-
const token={cancelled:false,handle:null};active.set(el,token);
|
|
61
|
-
try {token.handle=await runtime.mount({module,target:el,props,export:el.getAttribute('component')||'default'});if(token.cancelled)await token.handle.dispose();}
|
|
62
|
-
catch(e){active.delete(el);el.dispatchEvent(new CustomEvent('runtime-error',{detail:e}));throw e;}
|
|
63
|
-
}
|
|
64
|
-
for(const [el,token] of active){if(!el.isConnected||!root.contains(el)){active.delete(el);token.cancelled=true;await token.handle?.dispose();}}
|
|
140
|
+
for(const el of targetsWithin(currentRoot,'script[type][module]'))await registerScript(el);
|
|
141
|
+
// runtime-element and runtime-render are autonomous, not scan work.
|
|
65
142
|
}
|
|
66
143
|
function scheduleScan(){pending=pending.catch(()=>{}).then(scan);return pending;}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
144
|
+
function enableObservation(){
|
|
145
|
+
if(observer)observer.disconnect();observer=null;
|
|
146
|
+
if(!observe)return;
|
|
147
|
+
const Observer=doc.defaultView?.MutationObserver;
|
|
148
|
+
if(!Observer)return;
|
|
149
|
+
observer=new Observer(records=>{
|
|
150
|
+
for(const record of records)for(const node of record.addedNodes){
|
|
151
|
+
if(node.nodeType!==1)continue;
|
|
152
|
+
if(node.matches?.('script[type][module]')||node.querySelector?.('script[type][module]')){
|
|
153
|
+
scheduleScan();return;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
observer.observe(currentRoot,{subtree:true,childList:true});
|
|
158
|
+
}
|
|
159
|
+
async function appendScript(script,target=appendTarget){
|
|
160
|
+
if(script.localName!=='script')throw new TypeError('appendScript expects a script element');
|
|
161
|
+
const parent=target.nodeType===9?(target.head||target.body||target.documentElement):target;
|
|
162
|
+
parent.append(script);return script;
|
|
163
|
+
}
|
|
164
|
+
async function appendAndRegister(script,target=appendTarget){
|
|
165
|
+
await appendScript(script,target);
|
|
166
|
+
await registerScript(script);
|
|
167
|
+
return script;
|
|
168
|
+
}
|
|
169
|
+
async function setRoot(nextRoot,{registerExisting=false}={}){
|
|
170
|
+
if(!alive)throw new Error('HTML controller is disconnected');
|
|
171
|
+
if(rootDocument(nextRoot)!==doc)throw new Error('HTML root must remain in the same document');
|
|
172
|
+
if(observer)observer.disconnect();currentRoot=nextRoot;registry.notify();enableObservation();
|
|
173
|
+
if(registerExisting)await scheduleScan();
|
|
174
|
+
return nextRoot;
|
|
175
|
+
}
|
|
176
|
+
async function moveTo(nextRoot,opts){const moved=await setRoot(nextRoot,opts);appendTarget=nextRoot;return moved;}
|
|
177
|
+
async function disconnect(){
|
|
178
|
+
if(!alive)return;
|
|
179
|
+
alive=false;observer?.disconnect();registry.remove(controller);
|
|
180
|
+
await pending.catch(()=>{});
|
|
72
181
|
}
|
|
73
|
-
|
|
182
|
+
registry.add(controller);
|
|
183
|
+
enableObservation();
|
|
184
|
+
return {name:resolvedScope,scope:resolvedScope,runtime,defineElement,registerScript,appendScript,appendAndRegister,registerExisting:scheduleScan,scan:scheduleScan,setRoot,moveTo,disconnect,get root(){return currentRoot;},get appendTarget(){return appendTarget;}};
|
|
74
185
|
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/** Parse declarative object props without eval or executing JavaScript.
|
|
2
|
+
* Accepts JSON and a safe JS-literal subset (unquoted keys, single-quoted
|
|
3
|
+
* strings, trailing commas, nested arrays/objects). No expressions or code.
|
|
4
|
+
*/
|
|
5
|
+
export function parseObjectProps(value, context = 'props') {
|
|
6
|
+
if (value == null || value.trim() === '') return {};
|
|
7
|
+
if (value.length > 100_000) throw new SyntaxError(`${context}: props literal is too large`);
|
|
8
|
+
let offset = 0;
|
|
9
|
+
const error = (message) => { throw new SyntaxError(`${context}: ${message} at character ${offset + 1}`); };
|
|
10
|
+
const peek = () => value[offset];
|
|
11
|
+
const space = () => { while (offset < value.length && /\s/.test(peek())) offset++; };
|
|
12
|
+
const forbidKey = (key) => {
|
|
13
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') error(`Forbidden object key ${key}`);
|
|
14
|
+
return key;
|
|
15
|
+
};
|
|
16
|
+
function str() {
|
|
17
|
+
const quote = value[offset++];
|
|
18
|
+
let result = '';
|
|
19
|
+
while (offset < value.length) {
|
|
20
|
+
let c = value[offset++];
|
|
21
|
+
if (c === quote) return result;
|
|
22
|
+
if (c === '\n' || c === '\r') error('Unterminated string');
|
|
23
|
+
if (c !== '\\') { result += c; continue; }
|
|
24
|
+
if (offset >= value.length) error('Unterminated string escape');
|
|
25
|
+
c = value[offset++];
|
|
26
|
+
const escapes = {n:'\n', r:'\r', t:'\t', b:'\b', f:'\f', v:'\v', '0':'\0'};
|
|
27
|
+
if (Object.hasOwn(escapes, c)) { result += escapes[c]; continue; }
|
|
28
|
+
if (c === 'u' || c === 'x') {
|
|
29
|
+
const count = c === 'u' ? 4 : 2;
|
|
30
|
+
const hex = value.slice(offset, offset + count);
|
|
31
|
+
if (!new RegExp(`^[a-fA-F0-9]{${count}}$`).test(hex)) error('Invalid hex string escape');
|
|
32
|
+
result += String.fromCharCode(parseInt(hex, 16)); offset += count; continue;
|
|
33
|
+
}
|
|
34
|
+
if ('\\\'"/'.includes(c)) { result += c; continue; }
|
|
35
|
+
error(`Unsupported string escape \\${c}`);
|
|
36
|
+
}
|
|
37
|
+
error('Unterminated string');
|
|
38
|
+
}
|
|
39
|
+
function readValue(depth = 0) {
|
|
40
|
+
if (depth > 40) error('Props nesting is too deep');
|
|
41
|
+
space();
|
|
42
|
+
const c = peek();
|
|
43
|
+
if (c === '{') return object(depth + 1);
|
|
44
|
+
if (c === '[') return array(depth + 1);
|
|
45
|
+
if (c === '"' || c === "'") return str();
|
|
46
|
+
const next = value.slice(offset);
|
|
47
|
+
const number = next.match(/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/);
|
|
48
|
+
if (number) {
|
|
49
|
+
offset += number[0].length;
|
|
50
|
+
const n = Number(number[0]);
|
|
51
|
+
if (!Number.isFinite(n)) error('Numbers must be finite');
|
|
52
|
+
return n;
|
|
53
|
+
}
|
|
54
|
+
for (const [word, literal] of [['true',true],['false',false],['null',null]]) {
|
|
55
|
+
if (next.startsWith(word) && !/[\w$]/.test(next[word.length] ?? '')) {
|
|
56
|
+
offset += word.length; return literal;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
error('Expected a literal value (no expressions or functions)');
|
|
60
|
+
}
|
|
61
|
+
function key() {
|
|
62
|
+
space();
|
|
63
|
+
if (peek() === '"' || peek() === "'") return forbidKey(str());
|
|
64
|
+
const token = value.slice(offset).match(/^[A-Za-z_$][A-Za-z0-9_$]*/);
|
|
65
|
+
if (!token) error('Expected an object key');
|
|
66
|
+
offset += token[0].length;
|
|
67
|
+
return forbidKey(token[0]);
|
|
68
|
+
}
|
|
69
|
+
function object(depth) {
|
|
70
|
+
offset++; space();
|
|
71
|
+
const result = {};
|
|
72
|
+
if (peek() === '}') { offset++; return result; }
|
|
73
|
+
while (offset < value.length) {
|
|
74
|
+
const prop = key();space();
|
|
75
|
+
if (value[offset++] !== ':') error('Expected : after an object key');
|
|
76
|
+
const val = readValue(depth);
|
|
77
|
+
if (Object.hasOwn(result, prop)) error(`Duplicate object key ${prop}`);
|
|
78
|
+
result[prop] = val;
|
|
79
|
+
space();
|
|
80
|
+
const delimiter = value[offset++];
|
|
81
|
+
if (delimiter === '}') return result;
|
|
82
|
+
if (delimiter !== ',') error('Expected , or }');
|
|
83
|
+
space();
|
|
84
|
+
if (peek() === '}') { offset++; return result; }
|
|
85
|
+
}
|
|
86
|
+
error('Unclosed object');
|
|
87
|
+
}
|
|
88
|
+
function array(depth) {
|
|
89
|
+
offset++; space();
|
|
90
|
+
const result = [];
|
|
91
|
+
if (peek() === ']') { offset++; return result; }
|
|
92
|
+
while (offset < value.length) {
|
|
93
|
+
result.push(readValue(depth));space();
|
|
94
|
+
const delimiter = value[offset++];
|
|
95
|
+
if (delimiter === ']') return result;
|
|
96
|
+
if (delimiter !== ',') error('Expected , or ]');
|
|
97
|
+
space();
|
|
98
|
+
if (peek() === ']') { offset++; return result; }
|
|
99
|
+
}
|
|
100
|
+
error('Unclosed array');
|
|
101
|
+
}
|
|
102
|
+
space();
|
|
103
|
+
if (peek() !== '{') {
|
|
104
|
+
// Preserve the old object-only error wording for callers relying on it.
|
|
105
|
+
try { const json = JSON.parse(value); if (json === null || typeof json !== 'object' || Array.isArray(json)) throw new Error(`${context} must be a JSON object or safe object literal`); }
|
|
106
|
+
catch (e) { if (e.message.includes('must be a JSON object')) throw e; }
|
|
107
|
+
error('Props must be a JSON object or safe object literal');
|
|
108
|
+
}
|
|
109
|
+
const result = readValue();space();
|
|
110
|
+
if (offset !== value.length) error('Unexpected trailing content');
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/** Framework-neutral, document-owned HTML controller routing. No DOM observer or polling. */
|
|
2
|
+
const documents=new WeakMap();
|
|
3
|
+
const scopeAttributeNames=['scope','runtime','data-runtime'];
|
|
4
|
+
|
|
5
|
+
export function getScopeRegistry(doc){
|
|
6
|
+
if(!doc)throw new TypeError('HTML scope registry requires a document');
|
|
7
|
+
let registry=documents.get(doc);
|
|
8
|
+
if(!registry){
|
|
9
|
+
const controllers=new Set(),listeners=new Set();
|
|
10
|
+
registry={
|
|
11
|
+
controllers,
|
|
12
|
+
subscribe(fn){listeners.add(fn);return ()=>listeners.delete(fn);},
|
|
13
|
+
notify(){for(const fn of [...listeners])fn();},
|
|
14
|
+
add(controller){controllers.add(controller);registry.notify();},
|
|
15
|
+
remove(controller){if(controllers.delete(controller))registry.notify();},
|
|
16
|
+
resolve(element){return resolveScope([...controllers].filter(c=>c.alive),element);}
|
|
17
|
+
};
|
|
18
|
+
documents.set(doc,registry);
|
|
19
|
+
}
|
|
20
|
+
return registry;
|
|
21
|
+
}
|
|
22
|
+
export function requestedScope(element){
|
|
23
|
+
for(const attr of scopeAttributeNames){
|
|
24
|
+
const value=element.getAttribute?.(attr)?.trim();
|
|
25
|
+
if(value)return value;
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
export function containsRoot(root,element){
|
|
30
|
+
if(root===element)return true;
|
|
31
|
+
return Boolean(root?.contains?.(element));
|
|
32
|
+
}
|
|
33
|
+
export function resolveScope(controllers,element){
|
|
34
|
+
const explicit=requestedScope(element);
|
|
35
|
+
const possible=controllers.filter(c=>explicit ? c.scope===explicit : c.acceptUnscoped!==false);
|
|
36
|
+
const matches=possible.filter(c=>containsRoot(c.root,element));
|
|
37
|
+
if(matches.length===0){
|
|
38
|
+
// A completely missing scope is a pending dependency; a scope that exists
|
|
39
|
+
// but does not contain the element is a configuration error, as in solid-render.
|
|
40
|
+
if(possible.length){
|
|
41
|
+
return {controller:null,pending:false,error:new Error(explicit
|
|
42
|
+
? `HTML runtime scope ${JSON.stringify(explicit)} is registered but no matching controller root contains this element.`
|
|
43
|
+
: 'No eligible HTML runtime has a root containing this unscoped element.')};
|
|
44
|
+
}
|
|
45
|
+
return {controller:null,pending:true,reason:explicit?`Waiting for HTML scope ${JSON.stringify(explicit)}`:'Waiting for an eligible HTML runtime'};
|
|
46
|
+
}
|
|
47
|
+
const specific=matches.filter(candidate=>matches.every(other=>candidate===other||!containsRoot(candidate.root,other.root)));
|
|
48
|
+
if(specific.length===1)return {controller:specific[0],pending:false};
|
|
49
|
+
const names=matches.map(c=>c.scope).join(', ');
|
|
50
|
+
return {controller:null,pending:false,error:new Error(`Ambiguous HTML runtime ownership${explicit?` for scope ${JSON.stringify(explicit)}`:''}: ${names}. Use a distinct scope or non-overlapping roots.`)};
|
|
51
|
+
}
|
|
@@ -57,6 +57,7 @@ export function registerWebComponent(runtime,options={}) {
|
|
|
57
57
|
super();
|
|
58
58
|
this._runtimeHandle=null;
|
|
59
59
|
this._mounting=null;
|
|
60
|
+
this._waitController=null;
|
|
60
61
|
this._generation=0;
|
|
61
62
|
this._propertyValues=Object.create(null);
|
|
62
63
|
this._updateTask=Promise.resolve();
|
|
@@ -82,6 +83,7 @@ export function registerWebComponent(runtime,options={}) {
|
|
|
82
83
|
connectedCallback(){
|
|
83
84
|
for(const [key,value] of this._preUpgrade.splice(0))this[key]=value;
|
|
84
85
|
const version=++this._generation;
|
|
86
|
+
if(this._mounting)this._waitController?.abort();
|
|
85
87
|
queueMicrotask(()=>{if(this.isConnected&&version===this._generation)this._start(version);});
|
|
86
88
|
}
|
|
87
89
|
disconnectedCallback(){
|
|
@@ -115,19 +117,29 @@ export function registerWebComponent(runtime,options={}) {
|
|
|
115
117
|
if(this._runtimeHandle||this._mounting||!this.isConnected||version!==this._generation)return;
|
|
116
118
|
try{
|
|
117
119
|
const props=this._collectProps();
|
|
118
|
-
const
|
|
120
|
+
const waitController=new AbortController();
|
|
121
|
+
this._waitController=waitController;
|
|
122
|
+
const mountPromise=(async()=>{
|
|
123
|
+
await runtime.ready;
|
|
124
|
+
if(typeof runtime.waitForModule==='function')await runtime.waitForModule(module,{signal:waitController.signal});
|
|
125
|
+
if(waitController.signal.aborted||version!==this._generation||!this.isConnected)return null;
|
|
126
|
+
return runtime.mount({module,export:exportName,target:this,props});
|
|
127
|
+
})();
|
|
119
128
|
this._mounting=mountPromise;
|
|
120
129
|
const handle=await mountPromise;
|
|
130
|
+
if(!handle)return;
|
|
121
131
|
if(!this.isConnected||version!==this._generation){await handle.dispose();return;}
|
|
122
132
|
this._runtimeHandle=handle;
|
|
123
133
|
dispatch(this,'runtime-ready',{module,tag,handle});
|
|
124
|
-
}catch(e){this._report(e);}finally{
|
|
134
|
+
}catch(e){if(e?.name!=='AbortError')this._report(e);}finally{
|
|
135
|
+
if(this._waitController?.signal.aborted || version===this._generation)this._waitController=null;
|
|
125
136
|
this._mounting=null;
|
|
126
137
|
if(this.isConnected&&!this._runtimeHandle&&version!==this._generation)
|
|
127
138
|
queueMicrotask(()=>this._start(this._generation));
|
|
128
139
|
}
|
|
129
140
|
}
|
|
130
141
|
async _stop(){
|
|
142
|
+
this._waitController?.abort();this._waitController=null;
|
|
131
143
|
// A pending mount observes generation changes and disposes itself.
|
|
132
144
|
const handle=this._runtimeHandle;this._runtimeHandle=null;
|
|
133
145
|
if(handle){await handle.dispose();dispatch(this,'runtime-disposed',{module,tag});}
|