@innerHtml,@attribute,@publishSwcAppMessagedecide where they go메서드 본문은 평범한 값을 반환 —@innerHtml,@attribute,@publishSwcAppMessage가 둘 곳을 정함filter → before → handler → finallyhooks on events, observers, media queries, attributes, timers, routes and messages이벤트, 옵저버, 미디어쿼리, 속성, 타이머, 라우트, 메시지에 동일한filter → before → handler → finally훅behavior/replay) and RxJS-styleobserveMessage()페이로드·재생(behavior/replay)·RxJS식observeMessage()를 갖춘 앱 메시지 버스SWCis a lightweight, production-ready Web Components framework for building fast, modular, and maintainable SPAs without Virtual DOM overhead.
No arguments → no parentheses.Every decorator that can run without arguments is used bare (
@query,@innerHtml,@eventClick,@swcAppRouteGo, ...). The empty-call form@x()is a type error; pass arguments only when you have them (@innerHtml('.wrap'),@eventClick({ once: true })).
Register Web Components with automatic lifecycle management and DI support.
@elementDefine('my-component')classMyComponentextendsHTMLElement{@onInitializeonconstructor(service:MyService) {
}
}@elementDefine(name, options?)options:window,extends(customized built-in, e.g.'body'),useShadow(true | 'open' | 'closed'),observedAttributes,customElementRegistry.windowmay be another window than the one running the code (an iframe's, or a server-side DOM window) — the class then extends that window'sHTMLElement(class X extends w.HTMLElement).
Inheritance:decorators on a base class apply to its subclasses — lifecycle hooks, events, observers, timers, message/route subscriptions,@changedAttribute,@query,@state…
@elementDefined runs each lifecycle once (not once per level)classBasePageextendsHTMLElement{@onInitializeinit(@inject(AuthService.SYMBOL)auth:AuthService) {this.auth= auth; }@onDisconnectedcleanup() {/* … */}
}@elementDefine('my-page')classMyPageextendsBasePage{}// init / cleanup run for <my-page>Inject services into Web Components using the@onInitializedecorator.
@elementDefine('dashboard')classDashboardextendsHTMLElement{@onInitializeonconstructor(@inject(UserService.SYMBOL)userService:UserService) {this.userService= userService;
}
}Render HTML automatically when component connects to DOM.
@onConnectedBodyShadowrender() {return`<div>Hello, <span>${this.name}</span>!</div>`;
}Apply HTML to specific host element.
@innerHtmlLightupdateContent() {return`<p>Updated content</p>`;
}Replace child nodes with new content.
@replaceChildrenLightrenderNode() {returnthis.buildNode();// Node, Node[] or HTML string}Manage dynamic content insertion into named slots using slot decorators. Slots are placeholders in templates that can be updated dynamically.
@elementDefine('content-manager')classContentManagerextendsHTMLElement{// Method: Replace children in slot with HTML@event('.update-btn','click')@replaceChildrenHtmlSlot('main-content')updateContent() {return'<div>Updated content here</div>';
}// Method: Append HTML to slot@appendHtmlSlot('main-content')addMoreContent() {return'<p>Additional content</p>';
}// Method: Append text to slot@appendTextSlot('sidebar')addSidebarText() {return'Sidebar text: '+newDate().toISOString();
}// Method: Clear slot content@event('.clear-btn','click')@clearSlot('main-content')clearContent() {returntrue;
}@onConnectedBodyLightrender() {return`
<div>
<h2>Main Content</h2>
<!--[[ main-content ]]-->
<aside>
<h3>Sidebar</h3>
<!--[[ sidebar ]]-->
</aside>
<button class="update-btn">Update Content</button>
<button class="clear-btn">Clear Content</button>
</div>
`;
}
}Slot Syntax in Templates:
<!--[[ slot-id ]]-->- Named slot placeholder (HTML comment syntax)@applySlot Decorator Variants:
clearSlot(slotId)- Clear all contentprependHtmlSlot(slotId)- Add HTML at beginningprependTextSlot(slotId)- Add text at beginningappendHtmlSlot(slotId)- Add HTML at endappendTextSlot(slotId)- Add text at endreplaceChildrenHtmlSlot(slotId)- Replace all with HTMLreplaceChildrenTextSlot(slotId)- Replace all with textPosition Options:
exporttypeApplySlotPosition=
|'prepend'|'prependHtml'// Add HTML at beginning|'prependText'// Add text at beginning|'append'|'appendHtml'// Add HTML at end|'appendText'// Add text at end|'replaceChildren'|'replaceChildrenHtml'// Replace all with HTML|'replaceChildrenText'// Replace all with text|'clear';// Clear all contentReactive state management with automatic DOM updates when state changes. State values are accessible in templates and scripts using special syntax.
@elementDefine('counter-app')classCounterAppextendsHTMLElement{// Declare reactive state@state('count')count:number=0;@state('message')message:string='Hello';@state('isActive')isActive:boolean=false;@event('button','click')increment() {this.count++;// Triggers automatic DOM update}@onConnectedBodyLightrender() {return`
<div>
<!-- HTML directive: render HTML content -->
<!--[html @message@ ]-->
<!-- Text directive: render as text -->
<!--[text 'Count: ' + @count@ ]-->
<!-- Attribute binding with a:: prefix -->
<div a::title="'Count is'+@count@"></div>
<!-- Event binding with e:: prefix -->
<button e::click="@increment@()">Increment (@count@)</button>
<!-- Conditional rendering -->
<div a::style="@isActive@ ? 'color: green' : 'color: red'">
</div>
</div>
`;
}
}State Syntax in Templates:
Reading State Values(read only):
@stateName@- Read state value as text/attribute@stateName@()- Call state property as function (if it's a function)@expression@- Evaluate expressions with state variablesTemplate Directives:
<!--[html @state@ ]-->- Render state value as HTML<!--[text @state@ ]-->- Render state value as texta::attributeName="@state@"- Bind state to HTML attributese::eventName="@methodName@()"- Bind events to methodsHow @state Works:
@state('stateName')this.count++, setter is triggered@stateName@expressions in templates are re-evaluatedState Context Available:
@propertyName@- Access state properties@methodName@()- Call methods@expression@- Evaluate JavaScript expressionsImportant: State Properties are Read-Only in TemplatesState values accessed in templates via@stateName@syntax are read-only. You cannot assign values to state properties from within template expressions. State must be updated from component methods:
// ✅ CORRECT - Update state from method@event('button','click')increment() {this.count++;// Direct property assignment in code}// ❌ WRONG - Cannot assign in template<!--[text@count=5@ ]-->// This won't work - read-only in templates// ✅ CORRECT - Call method from template<buttone::click="@increment@()">Increment</button>Advanced Example:
@elementDefine('user-profile')classUserProfileextendsHTMLElement{@state('user')
user = {name:'Alice',age:30,email:'alice@example.com'};@state('isEditing')
isEditing =false;@event('.edit-btn','click')toggleEdit() {this.isEditing= !this.isEditing;
}@onConnectedBodyShadowrender() {return`
<div>
<!-- Display user info -->
<h2><!--[text @user.name@ ]--></h2>
<p a::title="Email: @user.email@">Age: @user.age@</p>
<!-- Conditional rendering based on state -->
<div a::style="@isEditing@ ? 'border: 1px solid blue' : 'border: none'">
<!--[html @isEditing@ ? '<input type="text" />' : '<span>View Mode</span>' ]-->
</div>
<button e::click="toggleEdit"> toggleEdit </button>
</div>
`;
}
}Attach event listeners to elements with optional filter support.
@event('#submit-btn','click')onSubmit(event:Event) {console.log('Submitted');
}// Filter events - only process matching events@event('button','click', {filter:(event, meta) =>{return(event.targetasHTMLElement)?.id==='critical-button';
}
})onCriticalClick(event:Event) {console.log('Critical action');
}@event Decorator Variants:
event(selector, type, options)- Full formevent(type, options)(no selector) - Listen on $this element. With the defaultroot: 'auto'it binds to the shadowRoot when the element has one (clicks inside the shadow bubble to it, but events dispatched on the element itself —el.click(), a parent'sel.dispatchEvent(...)— never go down into it); use{ root: 'light' }to bind the element itself,{ root: 'all' }for botheventAppHost(type, options)- Listen on $appHosteventWindow(type, options)- Listen on windoweventDocument(type, options)- Listen on documenteventDelegate(selector, type, options)- Event delegationeventDelegateLight(selector, type, options)- Delegate in light DOMeventDelegateShadow(selector, type, options)- Delegate in shadow DOMeventDelegateAll(selector, type, options)- Delegate in all DOMeventLight(selector, type, options)- Bind (non-delegated) in light DOMeventShadow(selector, type, options)- Bind (non-delegated) in shadow DOMeventAll(selector, type, options)- Bind (non-delegated) in both light & shadow DOMeventMutation(selector, type, options)- Delegate viaMutationObserverinstead of event bubbling (useful for non-bubbling events likefocus/blur);*Light/*Shadow/*Allvariants also available@event Options:
capture/once/passive- standard listener optionspreventDefault/stopPropagation/stopImmediatePropagation- applied before the handler runsfilter: (event, { currentThis, helper }) => boolean | Promise<boolean>- skip the handler when it returns falsebefore: (event, meta, args) => any- runs afterfilter; its return value is injectable with@eventBeforeReturnfinally: (event, meta, { args, result, error }) => any- always runs after the handlerdebounceTime/throttleTime(ms) /distinctUntilChanged- rate-limit the event streamremoveListener: (target, options) => void- cleanup callback on disconnectEvent-Type-Specific Aliases (eventClick, eventInputDelegateShadow, ...):
Beyond the genericevent(selector, type, options)form, every scope/delegate variant above is also pre-bound to ~59 common DOM event types, so you don't have to repeat the type string:
@eventClick('.logo')onLogoClick() { ... }// No selector → the element itself (same as @event('click')); bare form, no parens@eventClickonClick(event:MouseEvent) { ... }// Options without a selector → still the element itself@eventKeydown({filter:(e, meta) =>!meta.currentThis.isLocked})onKeydown(event:KeyboardEvent) { ... }// Same as: @eventDelegateLight('.item', 'click')@eventClickDelegateLight('.item')onItemClick(event:Event) { ... }Naming pattern:event+PascalCase(type)+ scope suffix (''(base) /DelegateLight/DelegateShadow/DelegateAll/Delegate/MutationLight/MutationShadow/MutationAll/Mutation/Light/Shadow/All/AppHost/Window/Document) — e.g.eventClick,eventClickDelegateShadow,eventDblclick,eventPointerdownDelegateAll,eventKeydownWindow.
The base alias (eventClick) takes an optional selector: bare@eventClickor@eventClick({...})listens on the element itself,@eventClick('.btn', {...})on the selector (there is no separate…Thisvariant). TheAppHost/Window/Documentvariants take no selector — only options — so they are used bare (@eventClickWindow) or with options. Never use empty parens. TheDelegate*/Light/Shadow/Allvariants require aselector.
Covered event types include mouse (click,dblclick,mousedown,mouseup,mousemove,mouseover,mouseout,mouseenter,mouseleave,contextmenu,wheel), keyboard (keydown,keyup,keypress), form (input,change,submit,reset,invalid,select), focus (focus,blur,focusin,focusout), drag & drop (dragstart,drag,dragend,dragenter,dragleave,dragover,drop), touch (touchstart,touchmove,touchend,touchcancel), pointer (pointerdown,pointerup,pointermove,pointerover,pointerout,pointerenter,pointerleave,pointercancel), clipboard (copy,cut,paste), animation/transition (animationstart/end/iteration/cancel,transitionstart/end/cancel/run), and misc (scroll,resize,load,error,toggle).
These aliases are generated inaddEventListener.tsfrom a small internalmakeTypedEventAliases<TEvent>(type)factory — adding a new event type is a one-line addition, not hand-written boilerplate. Any event type not covered still works via the generic@event(selector, 'your-type', options)/@eventDelegateLight(selector, 'your-type', options)etc.
@event-bound handlers normally receive fixed positional arguments ((event, legacyHelper1, legacyHelper2)). If you'd rather not memorize that order, decorate individual parameters instead — order and position no longer matter, and any parameter left undecorated in a method that uses none of these decorators still gets the legacy positional arguments (fully backward-compatible):
import{ event, eventObject, matchedElement, hostSet, helperHostSet, helperSet }from'@dooboostore/simple-web-component';@elementDefine('product-list')classProductListextendsHTMLElement{// Order can be anything you like@eventDelegateLight('.item','click')onItemClick(@matchedElement$item:Element,@eventObjectevent:Event) {console.log('clicked item:', $item, event);
}// Only need the host tree info? Just ask for @hostSet.@eventClickonHostClick(@hostSeths:HostSet) {console.log(hs.$appHost, hs.$hosts);
}// Need DOM helpers ($q, $qa, ...) too? Use @helperHostSet (superset of @hostSet) or @helperSet (helpers only).@eventClickonHostClickFull(@helperHostSetfull:HelperHostSet,@helperSethelpers:HelperSet) { ... }
}@eventObject- the original DOMEvent@matchedElement- the delegate-matched element (orevent.currentTargetfor direct bindings)@hostSet- host-ancestor-tree info only ($host,$parentHost,$hosts,$appHost,$appHosts,$firstHost,$lastHost,$firstAppHost,$lastAppHost)@helperHostSet-@hostSet+ DOM/window helpers ($d,$w,$q,$qa,$qi) +$this@helperSet- DOM/window helpers only ($d,$w,$q,$qa,$qi), no host-tree info@swcAppRouter- theRouterinstance ($appHost.router) directly, no@inject/DI container needed;undefinedoutside aSwcApptree@swcAppSimpleApplication- theSimpleApplication(DI container) instance ($appHost.simpleApplication) directly;undefinedoutside aSwcApptree or without a DI container.@injectis usually what you want instead — reach for this only when you need the container itself (e.g.simstanceManager).@swcAppHost- theSwcApphost element itself ($appHost) directly, when you just need the host and not the wholeHostSet;nulloutside aSwcApptree (matchesHostSet.$appHost's own type, unlike the two above which use optional chaining down toundefined)$w/$d(and thehelperpassed to every decorator'sfilter/before/finally) come from the element's window, resolved the same way everywhere: its own@elementDefine({ window })→ the nearest ancestor SWC host's → the globalwindow. Route subscribers (@subscribeSwcAppRouteChange) and app messages use the SwcApp's runtimewindowinstead (the per-request window under SSR).
The same mechanism (and the same 8 decorators, plus two more) also applies to:
@onInitialize/@onConnectedBefore/@onConnectedAfter/... lifecycle methods— can freely mix@hostSet/@helperHostSet/@helperSet/@swcAppRouter/@swcAppSimpleApplication/@swcAppHostwith@inject(...)on the same method.@subscribeSwcAppRouteChange— add@swcAppRouterEventto receive theRouterEventTyperegardless of position.@subscribeSwcAppMessage— add@swcAppMessageto receive theSwcAppMessageregardless of position.beforehook results—@eventBeforeReturn,@routeChangeBeforeReturn,@swcAppMessageBeforeReturn,@changedAttributeBeforeReturn,@mutationObserverBeforeReturn,@resizeObserverBeforeReturn,@intersectionObserverBeforeReturn,@eventMediaBeforeReturn,@setIntervalBeforeReturn,@setTimeoutBeforeReturninject the value returned by that decorator'sbeforeoption.@querySelectorParam,@attributeParam,@localStorageParam/@sessionStorageParam/@cookieParam/@indexedDbParam,@fetchParamgo a step further and actively compute/fetch a value instead of just handing you context — see "Value-Injecting Parameter Decorators" below.Note: once any parameter of a method is decorated, every parameter still gets a value — decorated ones receive their injected value, and any parameter left undecorated falls back to its legacy positional argument. So mixing is safe:onChanged(nv, @querySelectorParam('#x') el, name)still getsnv/namefrom the usual positional arguments.
@onConnectedAfteronconstructor(@inject(UserService.SYMBOL)userService:UserService,@hostSeths:HostSet) { ... }@subscribeSwcAppRouteChangeonRouteChanged(@helperSethelpers:HelperSet,@swcAppRouterEventre:RouterEventType) { ... }@subscribeSwcAppMessageonMessage(@hostSeths:HostSet,@swcAppMessagemsg:SwcAppMessage) { ... }Emit custom events with data.
@emitCustomEvent('$appHost','user-login')asynconLogin() {constuser =awaitthis.authService.login();return{ user };// Sent as event detail}@emitCustomEvent Decorator Variants:
emitCustomEvent(target, type, options)- Full formemit(target, type, options)- Short aliasemitCustomEvent(type, options)- Without a selector it emits from the element itself (emitAppHost/emitWindow/emitDocumenttake(type, options);emitLight/emitShadow/emitAlltake(selector, type, options))bubbles,composed,cancelable,filter,valueKey,attributeName,rootvalueKey: if the returned object has that key, only its value becomesdetail(otherwise the whole return value); default keyEMIT_CUSTOM_EVENT_METADATA_KEY. The method still returns the full value, so several emit/publish decorators can share one return objectPublish messages through the message bus when method completes.
// No arguments needed → bare form@publishSwcAppMessageasynconLogin() {constuser =awaitthis.authService.login();returnuser;// Published as message with data: user}@publishSwcAppMessage('user-profile-updated')updateProfile() {constprofile = {name:this.name,email:this.email};returnprofile;// Published as message with type: 'user-profile-updated'}@publishSwcAppMessage Decorator Variants:
publishSwcAppMessage- Bare decorator, publish without message typepublishSwcAppMessage(messageType)- Publish with specific message typepublishSwcAppMessage(messageType, { valueKey: 'customKey' })- Publish with custom value extractionpublishSwcAppMessage({ messageType: 'type', valueKey: 'customKey' })- Publish with options objectpublishMessage- Short aliaspublishMessage(messageType)- Short alias with message typepublishMessage(messageType, options)- Short alias with optionsUsing valueKey for Multiple Decorators:
@publishSwcAppMessage('event1', {valueKey:'detail1'})@publishSwcAppMessage('event2', {valueKey:'detail2'})handleMultipleEvents() {return{detail1: {type:'event1',data:'value1'},detail2: {type:'event2',data:'value2'}
};
}@emitCustomEvent(type, options)without a selector emits from $this. PassattributeNameso a parent can attach a handler through that attribute
(there is no default attribute name; set it explicitly). The attribute script getseventand$data(=event.detail).
@emitCustomEvent('navigate', {attributeName:'on-emit-navigate'})onNavClick(e:any) {return{path: e.target.dataset.path};
}// Parent: <app-header on-emit-navigate="$host.onHeaderNavigate(event, $data)"></app-header>@event(type, options)without a selector (or a typed alias without one, e.g. bare@eventClick) listens on the component element itself ($this). If the element has a shadow root, the defaultroot: 'auto'binds to the shadowRoot — clicks inside the shadow are received, events dispatched on the element itself are not. Use@event('click', { root: 'light' })(or'all') to also catch those.
@event('click')onHostClick(event:Event) {console.log('Host element clicked');
}Listen to events on the app root host element ($appHostselector). Enables selective event handling with filters.
// Listen to all user-action events from $appHost@eventAppHost('user-action')onUserAction(e:CustomEvent) {console.log('User action:', e.detail);
}// Filter specific events - loose coupling pattern@eventAppHost('user-action', {filter:(event, helper) =>event.detail?.type==='login'})onUserLogin(e:CustomEvent) {console.log('User logged in:', e.detail.userName);
}// Different component filtering same event differently@eventAppHost('user-action', {filter:(event, helper) =>event.detail?.type==='logout'})onUserLogout(e:CustomEvent) {console.log('User logged out');
}Query a single element. The defaultroot: 'auto'searches the shadow root if one exists, otherwise the light DOM.
@query('#form-input')formInput?:HTMLInputElement;@query('.card', {root:'shadow'})shadowCard?:HTMLElement;Query multiple elements.
@queryAll('input[type="text"]')textInputs?:HTMLInputElement[];@queryAll('li', {root:'shadow'})listItems?:HTMLLIElement[];pick, and shorthandsSpecial selectors:$this,$host,$parentHost,$appHost,$firstHost,$lastHost,$firstAppHost,$lastAppHost,$hosts,$appHosts,$window,$document. A function selector(currentThis, helper) => Element | Element[] | NodeList | nullis also accepted (norootoption).
@query// bare = the component itself (same as @query('$this')); @queryAll → [itself]self?:HTMLElement;// no arguments → no parentheses: @query() / @queryAll() are type errors@query('.item', {pick:'last'})// 'first' (default) | 'last' | number → single; 'all' | 'even' | 'odd' → arraylastItem?:HTMLElement;
@(queryIn('shadow','even')('.row'))// root + pick factory — the chained call needs the outer parenthesesevenRows?:HTMLElement[];queryLight/queryShadow/queryAllRoots(single),queryAllLight/queryAllShadow/queryAllAll(arrays)filter: (el, { currentThis, helper }) => booleannarrows the matchesnull/undefined/[]to a@queryfield removes the matched elements from the DOMWorks on both fields and methods; behavior depends on where it is applied.
typeconverts to Number/Boolean)nullremovesit,undefineddoes nothingOne string = attribute name on$this,two strings = (selector, attribute name).
@elementDefine('product-card')classProductCardextendsHTMLElement{// Field — product-id on $this@attribute('product-id')productId:string;// Field — data-id on the selector target@attribute('#user','data-id')userId:string;// Field — bare (field name = attribute name)@attributetitle:string;// Method — return value goes to nav[data-active] (null → removed)@attribute('nav','data-active')updateNav() {returnthis.section;// 'releases' | null}// Method — data-name on $this@attribute('data-name')setUserName() {returnthis.userName;
}
}Patterns:
@attribute-$this, name = field/method name@attribute('attr-name', options?)- attr-name on$this@attribute('selector', 'attr-name', options?)- selector target (nav,#user,.card,$appHost, ...)@attribute((this, helper) => el, 'attr-name')- function selector@attribute('selector', 'attr-name', { valueKey: 'k' })- (method) set onlykfrom the returned objecttype,filter,valueKey,root. Shorthands:attrAppHost,attrLight,attrShadow,attrAll(for $this just give the name:@attribute('name')){{= expr }}attribute value (see "Attribute Expressions")Always removes the attribute when the method runs — the name already says what to do, so the return value doesn't matter.
WithvalueKey, it removes only when that key is truthy. The return value passes through unchanged,
so it is safe on subscriber handlers that must not return a value (e.g. route changes).
// Runs before the other route handlers — reset the nav highlight@subscribeSwcAppRouteChange({order: -1})@removeAttribute('nav','href')handleNavReset() {returnundefined;// keep the chain going}Listen for attribute changes on the component itself (attributeChangedCallback). Handler arguments:(newValue, oldValue, name, helperHostSet);newValueis converted bytype(and a{{= expr }}value is evaluated first).
@elementDefine('reactive-component')classReactiveComponentextendsHTMLElement{@changedAttribute('product-id')onProductIdChanged(newValue:string,oldValue:string) {console.log(`Product changed from${oldValue}to${newValue}`);this.loadProduct(newValue);
}@changedAttribute('data-status', {type:Boolean})onStatusChanged(newValue:boolean) {console.log(`Status is now:${newValue}`);
}// Bare (or no name) → the method name is used as the attribute name@changedAttributestatus(newValue:string) { }
}@changedAttribute Options:
attributeName- Attribute name to listen for (optional, defaults to method name)type- Type converter:Number,Boolean, orStringwhile-'connected': skip changes while disconnected, and run once on connect with the current value (if set)filter/before/finally- same hook pattern as@event(beforeresult →@changedAttributeBeforeReturn)Works on both fields and methods.
@property(=$this+ same name) stays a plain field with no getter/setter and is only registered as anSSR hydration targetundefinedis ignored)Unlike@attribute,the first string is always a selector. A property on the element itself is just the field, so a name-only form would be meaningless.
@elementDefine('form-handler')classFormHandlerextendsHTMLElement{// Field — bare: own field (SSR hydration target)@propertydeclarerows:Row[];// Field — property of the selector target@property('#chart','data')chartData:number[];// Method — return value goes to #submit-btn.disabled@property('#submit-btn','disabled')updateSubmitState() {returnthis.hasErrors;
}// Method — same-named property on the selector target (input.value)@property('input')value() {returnthis.initialValue;
}
}Patterns:
@property-$this, name = field/method name@property('selector', options?)- same-named property on the target@property('selector', 'propertyName', options?)- propertyName on the target@property((this, helper) => el, 'propertyName')- function selector@callProperty('selector', 'methodName')(an array return value is spread as arguments;callPropertyLight/callPropertyShadow/callPropertyAllalso exist)Observe DOM mutations, element size changes, and viewport intersection declaratively. All three decorators share the same pattern as@event— they support optional selector (defaults to$this), root-based helpers, anddelegatemode.
Detect DOM changes (child add/remove, attribute changes, text changes) and react automatically.
@elementDefine('list-observer')classListObserverextendsHTMLElement{// Observe all changes under this component (light DOM)@mutationObserverLight({childList:true,attributes:true,subtree:true})onDomChanged(matchedEls:HTMLElement[],mutations:MutationRecord[],observer:MutationObserver) {console.log('DOM changed:', matchedEls.length, mutations);
}// Observe a specific selector (delegate mode - catches dynamically added nodes too)@mutationObserverDelegateShadow('.item', {childList:true})onItemChanged(matchedEls:HTMLElement[],mutations:MutationRecord[],observer:MutationObserver) {console.log('Item changed:', matchedEls);
}
}Callback signature:
matchedEls: HTMLElement[]- Elements matching the selector (1st arg). For the element itself (bare@mutationObserver/$this) it is just[element]— the added/removed nodes are inmutationsmutations: MutationRecord[]- Mutation records for this method's own targets (2nd arg)Bare@mutationObserver(no arguments) watches the element's whole subtree (childList+subtree) and is attachedbeforethe first render, so it sees the render itself. Pass options to narrow it (e.g.@mutationObserver({ childList: true })= direct children only).
observer: MutationObserver- The observer instance (3rd arg)How delegate works:
subtree: true; dynamically added/removed elements matching the selector are detected in the callback@mutationObserver Options:
MutationObserverInitoptions:childList,attributes,characterData,subtree,attributeFilter,attributeOldValue,characterDataOldValuedelegate- Observe root and filter by selector in callbackroot-'light' | 'shadow' | 'all' | 'auto'filter- Additional callback filterremoveObserver- Cleanup callback called on disconnect(target, options)@mutationObserver Decorator Variants:
mutationObserver(selector?, options?)- Full form (selector defaults to$this)mutationObserverLight(selector?, options?)- Observe light DOMmutationObserverShadow(selector?, options?)- Observe shadow DOMmutationObserverAll(selector?, options?)- Observe both light & shadowmutationObserverDelegate(selector?, options?)- Delegate mode (root observe)mutationObserverDelegateLight(selector?, options?)- Delegate in light DOMmutationObserverDelegateShadow(selector?, options?)- Delegate in shadow DOMmutationObserverDelegateAll(selector?, options?)- Delegate in all DOMDetect element size changes and react automatically.
@elementDefine('chart-widget')classChartWidgetextendsHTMLElement{// Re-render when component size changes (selector defaults to $this)@resizeObserverLightonResize(matchedEls:HTMLElement[],entries:ResizeObserverEntry[],observer:ResizeObserver) {this.redraw(entries[0]?.contentRect);
}// Watch specific elements (delegate mode tracks dynamically added ones)@resizeObserverDelegateShadow('.card')onCardResize(matchedEls:HTMLElement[],entries:ResizeObserverEntry[],observer:ResizeObserver) {console.log('Card resized:', matchedEls[0]?.clientWidth);
}
}Callback signature:
matchedEls: HTMLElement[]- Elements that were resized (1st arg)entries: ResizeObserverEntry[]- Original observer entries (2nd arg)observer: ResizeObserver- The observer instance (3rd arg)@resizeObserver Options:
box-'content-box' | 'border-box' | 'device-pixel-content-box'delegate- Track dynamically added/removed matching elementsroot-'light' | 'shadow' | 'all' | 'auto'filter- Additional callback filterremoveObserver- Cleanup callback called on disconnect(target, options)@resizeObserver Decorator Variants:
resizeObserver(selector?, options?)- Full form (selector defaults to$this)resizeObserverLight(selector?, options?)- Observe light DOMresizeObserverShadow(selector?, options?)- Observe shadow DOMresizeObserverAll(selector?, options?)- Observe both light & shadowresizeObserverDelegate(selector?, options?)- Delegate mode (root observe)resizeObserverDelegateLight(selector?, options?)- Delegate in light DOMresizeObserverDelegateShadow(selector?, options?)- Delegate in shadow DOMresizeObserverDelegateAll(selector?, options?)- Delegate in all DOMCombining with @resizeObserver delegate (dynamic tracking):Whendelegate: true, an internalMutationObserverautomatically observes the root so that elements matching the selector are tracked even after being dynamically added/removed from the DOM.
Detect when elements enter/exit the viewport (or a custom scroll container) and react automatically — useful for lazy loading, infinite scroll, and visibility tracking.
@elementDefine('lazy-image-list')classLazyImageListextendsHTMLElement{// Fire when any matching element crosses the viewport boundary@intersectionObserverLight('.lazy-img', {threshold:0})onImageVisible(matchedEls:HTMLElement[],entries:IntersectionObserverEntry[],observer:IntersectionObserver) {
entries.forEach(e=>{if(e.isIntersecting) (e.targetasHTMLImageElement).src= (e.targetasHTMLElement).dataset.src!;
});
}// Delegate mode tracks dynamically added elements too@intersectionObserverDelegateShadow('.card', {threshold: [0,0.5,1] })onCardIntersect(matchedEls:HTMLElement[],entries:IntersectionObserverEntry[],observer:IntersectionObserver) {console.log('intersection ratio:', entries[0]?.intersectionRatio);
}
}Callback signature:
matchedEls: HTMLElement[]- Elements matching the selector (1st arg)entries: IntersectionObserverEntry[]- Original observer entries (2nd arg)observer: IntersectionObserver- The observer instance (3rd arg)@intersectionObserver Options:
IntersectionObserverInitoptions:threshold,rootMargin(note: its own scroll-containerrootis passed asintersectionRoot, since the decorator'srootoption already means light/shadow/all/auto selector scope)delegate- Observe root and filter by selector in callback (dynamically added/removed elements are tracked too)root-'light' | 'shadow' | 'all' | 'auto'filter- Additional callback filterremoveObserver- Cleanup callback called on disconnect(target, options)@intersectionObserver Decorator Variants:
intersectionObserver(selector?, options?)- Full form (selector defaults to$this)intersectionObserverLight(selector?, options?)- Observe light DOMintersectionObserverShadow(selector?, options?)- Observe shadow DOMintersectionObserverAll(selector?, options?)- Observe both light & shadowintersectionObserverDelegate(selector?, options?)- Delegate mode (root observe)intersectionObserverDelegateLight(selector?, options?)- Delegate in light DOMintersectionObserverDelegateShadow(selector?, options?)- Delegate in shadow DOMintersectionObserverDelegateAll(selector?, options?)- Delegate in all DOMCleanup:All three observers are automatically disconnected when the component is removed from the DOM.removeObservercallbacks (if provided) are invoked during cleanup.
Surgically add, replace, or remove nodes in the DOM with fine-grained control.
@elementDefine('content-updater')classContentUpdaterextendsHTMLElement{@event('button','click')@replaceChildrenupdateContent() {return`<div>New content</div>`;
}@event('.append-btn','click')@insertBeforeEndappendContent() {return`<p>Appended content</p>`;
}@event('.prepend-btn','click')@insertAfterBeginprependContent() {return`<p>Prepended content</p>`;
}@onConnectedBodyLightrender() {return`
<div>
<button class="append-btn">Append</button>
<button class="prepend-btn">Prepend</button>
<button>Replace</button>
</div>
`;
}
}@applyNode Decorator Variants:
applyNode(selector?, options?)/apply/node- Full form (selector defaults to$this). Position: with no options at all it isreplaceChildren; once you pass an options object withoutpositionit isbeforeEnd(append) — bare@applyAppHostappends too. Passpositionexplicitly when it matters;applyAppHost/applyLight/applyShadow/applyAll@replaceChildren/@replaceChildrenLight- Replace all children@insertBeforeEnd/@insertBeforeEndLight/@insertBeforeEndShadow- Append to end@insertAfterBegin/@insertAfterBeginLight- Prepend to beginning@innerHtml/@innerHtmlLight/@innerHtmlShadow- Set innerHTML@innerText/@innerTextLight/@innerTextShadow- Set innerText@clearChildrenNode/@clearChildrenLight- Remove all children@removeNode- Remove the target element itself (bare /'$this'removes the component)Declaration = action(@removeNode,@clearChildrenNode,@removeAttribute, and@addClass/@removeClass/@toggleClasswith the class name written in the decorator):
they run whatever the method returns. Give avalueKeyto decide per call — then they run only when that key is truthy
(@toggleClass('.x', 'open'): no return toggles,true/falseforces on / off).
@eventClick('.close')@removeNode('.toast')// always removesclose() {}@removeNode('.editor', {valueKey:'close'})// removes only when close is truthysave() {return{close:this.saved}; }position,root,filter,fallback,valueKey. Ready-madefilterhelpers:skipIfSameTagPresent,skipIfExists(selector),skipIfEmpty,applyIfChanged(e.g.@innerHtmlLight({ filter: skipIfExists('my-page') }))Position Options:
exporttypeApplyNodePosition=
|'beforeBegin'// Before element|'afterBegin'// After element opens|'beforeEnd'// Before element closes|'afterEnd'// After element closes|'replace'// Replace element|'replaceChildren'// Replace children|'innerHtml'// Set innerHTML|'innerText'// Set innerText|'remove'// Remove element|'clearChildren';// Remove all childrenApply styles and classes dynamically with@applyStyleand@applyClass.
@elementDefine('styled-component')classStyledComponentextendsHTMLElement{@state('isActive')
isActive =false;@event('button','click')@updateStyletoggleStyle() {return{color:this.isActive?'green':'red',fontSize:'16px'};
}@event('.toggle-btn','click')@updateClasstoggleState() {return{'active':this.isActive,'disabled': !this.isActive};
}@onConnectedBodyLightrender() {return`
<div>
<button class="toggle-btn">Toggle</button>
<button>Update Style</button>
</div>
`;
}
}@applyStyle Variants:(each takes(selector?, options?); bare form targets$this)
setStyle- Clear and set stylesupdateStyle- Update/merge stylesremoveStyle- Remove specific stylesapplyStyle(selector, action?)/style- action'set' | 'update' | 'remove'(default'update');styleAppHost/styleLight/styleShadow/styleAll(for $this use bare@setStyle/@updateStyle/@removeStyle)@applyClass Variants:(each takes(selector?, classMapOrOptions?, options?); bare form targets$this)
setClass- Replace all classesupdateClass- Toggle classes by a{ className: boolean }mapaddClass/removeClass/toggleClass— with the class name in the decorator (@addClass('.x', 'on')) the declaration is the action (see "Declaration = action" above)applyClass(selector, action?)/cls- action'set' | 'update' | 'add' | 'remove' | 'toggle'(default'update');clsAppHost/clsLight/clsShadow/clsAll(for $this use bare@setClass/@updateClass/@addClass/@removeClass/@toggleClass)root(e.g.{ root: 'auto', valueKey: 'k' }); an object withoutrootis read as a class mapLifecycle hooks allow you to execute code at specific points in a component's lifecycle. All lifecycle decorators support optionalorderparameter for execution ordering.
@elementDefine('my-component')classMyComponentextendsHTMLElement{@onInitialize({order:0})onInit() {// Called during component constructionconsole.log('Component initialized');
}@onConnectedBefore({order:0})beforeConnected() {// Called on connect, before rendering}@onConnectedAfter({order:1})afterConnected() {// Called on connect, after rendering and observer setup}@onConnectedBodyrender() {// Called when element enters DOM — renders the returned HTML (variants: @onConnectedBodyShadow / @onConnectedBodyLight)return`<div>...</div>`;
}@onDisconnectedBeforebeforeDisconnected() {// Called before element disconnects from DOM}@onDisconnected// alias of @onDisconnectedAfteronDisconnected() {// Called when element leaves DOM}@onAdoptedBeforebeforeAdopted() {// Called before element is adopted into new document}@onAdopted// alias of @onAdoptedAfteronAdopted() {// Called when element is adopted into new document}@onConnectedSwcApp({order:0})onSwcAppConnected() {// Called when the element registers with a connected SwcApp host// (deferred until SwcApp.connect() if the app is not connected yet)}@onConnectedCompleted({order:0})onConnectedCompleted() {// Called after all connected lifecycle hooks complete}
}Lifecycle Execution Order (connectedCallback):
@onInitialize- once, on first init (DI via@injectparameters)@onConnectedSwcApp- the element registers with its app host (deferred untilconnect()if needed)@onConnectedBefore- before rendering@onConnectedBody(Shadow/Light) - render the returned HTML/Node (skipped for SSR-rendered elements)@onConnectedAfter- after rendering@onConnectedCompleted- always runs last (in afinallyblock), thentrigger: 'connectedDone'replays fireDisconnect:@onDisconnectedBefore→ cleanup →@onDisconnected(@onDisconnectedAfter). Adopt:@onAdoptedBefore→@onAdopted(@onAdoptedAfter).
Order Parameter:All lifecycle decorators supportorder?: numberfor controlling execution sequence:
@onConnectedBefore({order:0})// Runs firstonFirst() { }@onConnectedBefore({order:1})// Runs secondonSecond() { }Declaratively schedule work tied to the component. Every timer the decorator starts is cleared automatically on disconnect. There are two modes (typeoption):
type: 'onConnected'(default) - starts on connect and calls the decorated method itself on every tick/fire.type: 'returnValue'- nothing starts automatically. When you call the method, its return value (a function, or the function undervalueKey) becomes the repeating/delayed callback, called with(id: number). The original return value passes through. Aliases:@setIntervalReturnValue,@setTimeoutReturnValue,@requestAnimationFrameReturnValue.@elementDefine('live-clock')classLiveClockextendsHTMLElement{// Auto-start on connect@setInterval(1000, {parameter:(set) =>[Date.now()],created:(set, id) =>console.log('interval started, timer id:', id)
})tick(now:number) {this.textContent=newDate(now).toLocaleTimeString();
}// Fires once, 3 seconds after connect@setTimeout(3000)onceAfter3s() {console.log('3 seconds have passed');
}// 'returnValue': starts when you call startPolling()@setIntervalReturnValue(900)startPolling() {return(id:number) =>console.log('interval', id,'ticked');
}
}@setInterval(interval, options?)/@setTimeout(delay, options?):
interval/delay- milliseconds.setIntervalrepeats;setTimeoutfires once.type-'onConnected'(default) |'returnValue'parameter?: (set: HelperHostSet) => any[]- (onConnected) arguments for each tick/fire; omitted → no arguments.created?: (set: HelperHostSet, id: number) => void- called once right after the timer is armed. For logging only; cleanup is automatic.filter/before/finally- (onConnected) per-tick hooks;beforeresult →@setIntervalBeforeReturn/@setTimeoutBeforeReturn.valueKey?: symbol | string- which key of the returned object holds the callback function (defaults toSET_INTERVAL_METADATA_KEY/SET_TIMEOUT_METADATA_KEY).@requestAnimationFrame(options?)(bare form also works) - same two modes. The frame callback is(timestamp, prevValue) => any: returnundefined/nullto stop the loop, anything else is passed asprevValueto the next frame. Options:type,created,valueKey.
@requestAnimationFrameReturnValue// starts when you call animate()animate() {return(ts:number, prev =0) =>(prev <100? prev +1:null);// stops at 100}@aroundwraps a method (before(helper, args)→ new args,after(helper, result)→ new result,finally(helper, { args, result, error })) or a field (get(helper, stored),set(helper, incoming)). Async hooks are supported when the method is async.
@around({before:(h,args: [string]) =>[args[0].trim()],after:(h,r:string) =>r +'!'})greet(name:string) {return`hi${name}`; }@around({set:(h,v:string) =>v?.trim() })label:string='';@eventMedia(query, eventType, options?)/@eventMediaChange(query, options?)subscribe towindow.matchMedia(query)on connect and unsubscribe on disconnect. Handler arguments:(event: MediaQueryListEvent, helperHostSet). Options:filter,before,finally.
@eventMediaChange('(max-width: 600px)')onMobile(e:MediaQueryListEvent) {this.compact= e.matches; }{{= }})An attribute value of the form{{= expression }}is evaluated as JavaScript instead of being used as a string:
<product-detailproduct-id="{{= $host.selectedId }}"></product-detail>@attributefield getter and before it is passed to@changedAttributehandlersthisis the element that owns the attribute; the helper/host variables are in scope:$this,$host,$parentHost,$hosts,$firstHost,$lastHost,$appHost,$appHosts,$d,$w,$q,$qa,$qitypeconversion applies); on error the raw string is usedTemplate bindings inside@onConnectedBody*HTML (@state@,<!--[html ]-->,<!--[text ]-->,a::,e::, andp::for DOM properties) are described in the@statesection.
SWC provides a powerful message bus system for inter-component communication through SwcApp. Components can publish and subscribe to typed messages while connected.
Subscribe to messages while component is connected to DOM.
@elementDefine('notification-panel')classNotificationPanelextendsHTMLElement{@subscribeSwcAppMessageonAnyMessage(message:SwcAppMessage) {console.log('Received message:', message);
}@subscribeSwcAppMessage('user-login')onUserLogin(message:SwcAppMessage<{ username:string}>) {console.log(`Welcome${message.data?.username}`);
}@subscribeSwcAppMessage('user-login', {filter:(msg, currentThis) =>msg.data?.username==='admin'})onAdminLogin(message:SwcAppMessage) {console.log('Admin logged in!');
}
}@subscribeSwcAppMessage Options:filter(message, currentThis),before(message, currentThis)(result →@swcAppMessageBeforeReturn),finally(message, currentThis, { args, result, error }),subject,trigger(see "Message Replay"). Every matching subscriber runs (fire-and-forget); return values do not stop other subscribers.
Shortcuts forsubject:@subscribeSwcAppMessageBehavior(type, options?)={ subject: 'behavior' }(a late subscriber gets the last message),@subscribeSwcAppMessageReplay(type, options?)={ subject: 'replay' }(gets the whole buffer). Same forms as@subscribeSwcAppMessage(bare, type, options, type + options); the other options pass through.
Publish a message from a method's return value.
@elementDefine('login-form')classLoginFormextendsHTMLElement{// No arguments needed → bare form@publishSwcAppMessageasynconSubmit() {constuser =awaitthis.authService.login(this.username,this.password);returnuser;// Published as message with data: user}@publishSwcAppMessage('user-profile-updated')updateProfile() {constprofile = {name:this.name,email:this.email};returnprofile;// Published as message with type: 'user-profile-updated'}
}@publishSwcAppMessage Decorator Variants:
publishSwcAppMessage- Publish without message type (bare)publishSwcAppMessage(messageType)- Publish with specific message typepublishSwcAppMessage(messageType, { valueKey: 'customKey' })- Publish with custom value extractionpublishSwcAppMessage({ messageType: 'type', valueKey: 'customKey' })- Publish with options objectpublishMessage- Short aliaspublishMessage(messageType)- Short alias with message typepublishMessage(messageType, options)- Short alias with optionsUsing valueKey for Multiple Decorators:
@publishSwcAppMessage('event1', {valueKey:'detail1'})@publishSwcAppMessage('event2', {valueKey:'detail2'})handleMultipleEvents() {return{detail1: {type:'event1',data:'value1'},detail2: {type:'event2',data:'value2'}
};
}SwcAppMessage Structure:
typeSwcAppMessage<T =any> = {publisher?:any;// Component that published the messagedata?: T;// Payload datatype?:string;// Optional message type/category};Key Features:
$appHost)Usage Example:
// Publisher component@elementDefine('product-list')classProductListextendsHTMLElement{@publishSwcAppMessage('product-selected')selectProduct(productId:string) {constproduct =this.findProduct(productId);returnproduct;
}
}// Subscriber component@elementDefine('product-detail')classProductDetailextendsHTMLElement{@subscribeSwcAppMessage('product-selected')onProductSelected(message:SwcAppMessage<Product>) {this.displayProduct(message.data);
}
}SWC provides a flexible Mixin-based architecture for creating SwcApp elements that extend different HTMLElement types. This enables building SPAs with any semantic HTML element as the root.
// SwcApp, SwcAppBody, ... are createElement helpers: SwcAppBody(w, data?) creates the element.// defineSwcApp, defineSwcAppBody, ... register the custom element.import{SwcApp,// swc-app (HTMLElement)SwcAppBody,// swc-app-body (HTMLBodyElement, is="body")SwcAppDiv,// swc-app-div (HTMLDivElement, is="div")SwcAppSection,// swc-app-section (HTMLElement, is="section")SwcAppMain,// swc-app-main (HTMLElement, is="main")SwcAppArticle,// swc-app-article (HTMLElement, is="article")SwcAppHeader,// swc-app-header (HTMLElement, is="header")SwcAppFooter,// swc-app-footer (HTMLElement, is="footer")SwcAppNav,// swc-app-nav (HTMLElement, is="nav")SwcAppAside,// swc-app-aside (HTMLElement, is="aside")defineSwcAppAll,// Register all SwcApp variants at onceswcAppFactories// Array of all definition functions}from'@dooboostore/simple-web-component';<!DOCTYPEhtml><html><head><metacharset="UTF-8"><title>My SPA</title></head><!-- Use is="swc-app-body" for semantic HTML --><bodyid="app"is="swc-app-body"><root-router></root-router></body><scriptsrc="bundle.js"></script></html>// index.tsimport'reflect-metadata';import{ defineSwcAppBody,SwcAppInterface, defineSwcAppAll }from'@dooboostore/simple-web-component';importbootFactoryfrom'./bootFactory';constw =window;
w.document.addEventListener('DOMContentLoaded',async() => {constcontainer =Symbol('app');// Initialize services and componentsawaitdefineSwcAppBody(w);// Get app root elementconstappElement = w.document.querySelector('#app')asSwcAppInterface;if(appElement &&typeofappElement.connect==='function') {
appElement.connect({path:'/',routeType:'path',container: container,onStartedLazyDefineComponent: [yourComponentFactory1, yourComponentFactory2],window: w,onEngineStarted:() =>{console.log('🚀 Application started successfully');
}
});
}
});Mainconnect()options (SwcAttributeConfigType):routeType('path' | 'hash' | 'element'),path(first URL),container(DI container symbol),window,onStartedLazyDefineComponent(component factories),onEngineStarted(sp, app),onConnected/onDisconnected,onConnectedChildBefore/onConnectedChildAfter,onDisconnectedChildBefore/onDisconnectedChildAfter,onChildrenConnectedDone,onChildrenRouteChanged,messageReplayBufferSize,ssr,otherInstanceSim.
App host methods:routing(path),back(),forward(),reload(),publishMessage(message),observeMessage(...),connectedElements().
// Component factoryexportdefault(w:Window) => {consttagName ='my-component';constexisting = w.customElements.get(tagName);if(existing)returntagName;@elementDefine(tagName, {window: w })classMyComponentextendsw.HTMLElement{@onInitializeonconstructor(@inject(MyService.SYMBOL)service:MyService) {this.service= service;
}
}returntagName;
};// Boot factoryexportdefault(w:Window,container:symbol) => {
serviceFactories.forEach(s=>s(container));
};// Entry pointconstcontainer =Symbol('app');awaitdefineSwcAppBody(w);constappElement = w.document.querySelector('#app')asSwcAppInterface;
appElement.connect({
container,window: w,onStartedLazyDefineComponent: [...pageFactories, ...componentFactories],onEngineStarted:() =>{
appElement.innerHTML='<root-router></root-router>';
}
});SWC provides declarative routing with automatic path matching, order-based execution, and optional propagation control.
@elementDefine('root-router')classRootRouterextendsHTMLElement{@subscribeSwcAppRouteChange(['','/'], {order:0})@innerHtmlLighthandleHome(routerPathSet:RouterEventType) {return`<landing-page/>`;
}@subscribeSwcAppRouteChange(['/products'], {order:1})@innerHtmlLighthandleProducts(routerPathSet:RouterEventType) {return`<products-list/>`;
}@subscribeSwcAppRouteChange(['/product/{id}'], {order:2})@innerHtmlLighthandleProductDetail(routerPathSet:RouterEventType) {const{ id } = routerPathSet.pathData;return`<product-detail product-id="${id}"/>`;
}@subscribeSwcAppRouteChange(['/{tail:.*}'], {order:999})@innerHtmlLighthandle404(routerPathSet:RouterEventType) {return`<not-found-page/>`;
}
}1. Path Matching
@subscribeSwcAppRouteChange({ order: -1 })- matches all routes(currentThis) => string | string[]['', '/']- matches home route only;['/products']matches/productsonly['/products/{tail:.*}']- use a regex segment to match/products/...['/product/{id}']- capturesid([^/]+); custom regex:{slug:[a-z0-9-]+}['/{tail:.*}']- matches any path (use for 404)2. Order-Based ExecutionHandlers of one element run inorder(lowest first, default 0). The first matching handler that returns a value stops the rest ofthat element'schain:
@subscribeSwcAppRouteChange(['','/'], {order:0})// Checked first@subscribeSwcAppRouteChange(['/admin'], {order:1})// Checked second@subscribeSwcAppRouteChange(['/{tail:.*}'], {order:999})// Checked last (404)3. Propagation Control
undefined/null, aftervalueKeyextraction) → stops this element's remaining handlersundefined/null→ continues to the next handler// This handler stops propagation (returns HTML)@subscribeSwcAppRouteChange(['/admin'], {order:1})@innerHtmlLighthandleAdmin(routerPathSet:RouterEventType) {return`<admin-panel/>`;// ✅ Stops here}// This handler continues propagation (no return value)@subscribeSwcAppRouteChange({order: -1})onRouteChange(routerPathSet:RouterEventType) {console.log('Route changed:', routerPathSet.path);// No return value → continues to next handler}4. When to run:on(default'match'— the behavior above)
on | Runs when |
|---|---|
'match' | the path matches (every route change) |
'enter' | it didn't match before and matches now (also the replay when the element connects) |
'update' | it keeps matching but the path or query changed |
'leave' | it matched before and doesn't now — only for elements that stay connected (a page that gets replaced should clean up inonDisconnected) |
'beforeLeave' | before leaving this route; returnfalse(orPromise<false>) to cancel the navigation |
A bare@subscribeSwcAppRouteChange(no path) matches every route: with'match'it runs on every change,'update'on every URL change,'enter'once.
@subscribeSwcAppRouteChange('/releases/{releaseSeq}/tasks/{taskSeq}', {on:'update'})@innerHtml('.card')swapCard(@swcAppRoutePathVariable('taskSeq')taskSeq:string) { ... }// same page, only the card changes@subscribeSwcAppRouteChange('/edit/{id}', {on:'beforeLeave'})confirmLeave() {returnthis.dirty?confirm('Discard your changes?') :true; }beforeLeavecoversrouter.go(...)and the browser back/forward buttons. Back/forward have already changed the history when the guard runs, so a cancel restores the previous URL withpushState. CallingpushState/replaceStatedirectly and closing the tab (beforeunload) are not covered. The handler receives the route being left withto(the targetRouteData).
5. Route parameter decorators
No prefix meansfirst;First/Lastpick the first/last value when a key repeats; plural forms return every value.
Query (?tag=a&tag=b) | Value | Path (/a/{id}/b/{id}←/a/1/b/2) | Value |
|---|---|---|---|
@swcAppRouteQueryParam('tag') | 'a'(nullif absent) | @swcAppRoutePathVariable('id') | '1' |
@swcAppRouteFirstQueryParam('tag') | 'a' | @swcAppRouteFirstPathVariable('id') | '1' |
@swcAppRouteLastQueryParam('tag') | 'b' | @swcAppRouteLastPathVariable('id') | '2' |
@swcAppRouteQueryParams('tag') | ['a','b'] | @swcAppRoutePathVariables('id') | ['1','2'] |
@swcAppRouteQueryParamObject | { tag: 'a' } | @swcAppRoutePathVariableObject | { id: '1' } |
@swcAppRouteFirstQueryParamObject | { tag: 'a' } | @swcAppRouteFirstPathVariableObject | { id: '1' } |
@swcAppRouteLastQueryParamObject | { tag: 'b' } | @swcAppRouteLastPathVariableObject | { id: '2' } |
@swcAppRouteQueryParamsObject | { tag: ['a','b'] } | @swcAppRoutePathVariablesObject | { id: ['1','2'] } |
@swcAppRouteURLSearchParams | URLSearchParams |
@subscribeSwcAppRouteChange('/releases/{releaseSeq}')load(@swcAppRoutePathVariable('releaseSeq')seq:string,@swcAppRouteQueryParam('tab')tab:string|null) { ... }@swcAppRouterEventfor the whole event — any parameter left undecorated still gets the positional route argument even after others are decorated (see the parameter-decorator note earlier in this doc).route.pathDatakeeps its old behavior: a repeated variable name holds thelastvalue. Use the decorators above to pick first/last explicitly.On fields.The same decorators work on fields. The SwcApp fills them on every route change and, for an element attached later, from the current route before its first render. Inside a route handler of the same element, the field already holds this route's value.
classUserPageextendsHTMLElement{@swcAppRouteQueryParam('tab')tab?:string|null;// query: no pattern needed@swcAppRoutePathVariable('id', {path:'/user/{id}'})userId?:string;// path variables need { path }@swcAppRoutePathVariableObject({path: ['/user/{id}','/member/{id}'] })vars?: {id?:string};@onConnectedBodyShadowrender() {return`<h1>${this.userId}·${this.tab}</h1>`; }
}pathtakes the same forms as@subscribeSwcAppRouteChange(string, array, or(currentThis) => ...). When it doesn't match the current route, path fields are empty (undefined,[],{}); query fields keep updating. A path-variable field without{ path }warns once at definition.HTMLElementproperties (id,lang,title,prefix, ...): assigning to them changes the element instead.6. Navigating:@swcAppRoute/@swcAppRouteGo
The method returns where to go; the decorator calls the element's SwcApp router — noRouterinjection needed.@swcAppRoute({ type })picks the Router method (default'go');@swcAppRouteGo,@swcAppRoutePush, ... are aliases with the type fixed.
@eventDelegateAll('[data-href]','click')@swcAppRouteGoonNavigate(@matchedElementel:Element) {returnel.getAttribute('data-href');
}@eventShadow('form','submit', {preventDefault:true})@swcAppRouteGoasynconSubmit() {awaitthis.authService.login(...);return'/releases';// after an async body too}Return value (go) | Result |
|---|---|
'/path'/{ path, searchParams } | router.go(...) |
{ path, replace?, state?, scrollToTop? } | router.gowith those options |
| a number | history move (-1= back) |
undefined/null/false | no navigation (conditional) |
@swcAppRoute({ type: 'push' })or the alias with the type fixed (@swcAppRoutePush/@swcAppRoutePush({ state, valueKey })):| Alias | type | Return value |
|---|---|---|
@swcAppRouteGo | go(default) | see the table above |
@swcAppRoutePush/@swcAppRouteReplace | push/replace | RouteAction |
@swcAppRoutePushUpsertSearchParam/@swcAppRouteReplaceUpsertSearchParam | pushUpsertSearchParam/replaceUpsertSearchParam | { key: value | value[] } |
@swcAppRoutePushAddSearchParam/@swcAppRouteReplaceAddSearchParam | pushAddSearchParam/replaceAddSearchParam | [[key, value], ...] |
@swcAppRoutePushDeleteSearchParam/@swcAppRouteReplaceDeleteSearchParam | pushDeleteSearchParam/replaceDeleteSearchParam | string | string[] |
@swcAppRoutePushDeleteHashSearchParam/@swcAppRouteReplaceDeleteHashSearchParam | pushDeleteHashSearchParam/replaceDeleteHashSearchParam | string | string[] |
push/replacego throughbeforeLeaveguards likego. The search-param methods keep the path, so they don't count as leaving and skip the guards.
@swcAppRoute({ type, state, valueKey, filter }), plusreplace/scrollToTopforgo(@swcAppRouteGo({ replace: true })).valueKeypicks the target out of a return object when stacked with other output decorators (default keySWC_APP_ROUTE_METADATA_KEY).filter(router, value, { currentThis, helper })→falseskips the navigation (same shape as the other output decorators'filter;valueis aftervalueKeyextraction). E.g.@swcAppRouteGo({ filter: (router, to) => to !== router.value.path }).router.go, sobeforeLeaveguards apply.@elementDefine('accommodation-router')classAccommodationRouterextendsHTMLElement{// Global route logger (order: -1 runs first, no path pattern = matches all routes)@subscribeSwcAppRouteChange({order: -1})onRouteChange(routerPathSet:RouterEventType) {console.log('[Route Change]', {path: routerPathSet.path,pathData: routerPathSet.pathData,timestamp:newDate().toISOString()
});// No return → continues to route handlers}// Home route@subscribeSwcAppRouteChange(['','/'], {order:0})@innerHtmlLighthandleHome(routerPathSet:RouterEventType) {console.log('[Route Handler] Home');return`<landing-page/>`;
}// List route@subscribeSwcAppRouteChange(['/list'], {order:1})@innerHtmlLighthandleList(routerPathSet:RouterEventType) {console.log('[Route Handler] List');return`<list-page/>`;
}// Detail route with dynamic parameter@subscribeSwcAppRouteChange(['/detail/{productId}'], {order:2})@innerHtmlLighthandleDetail(routerPathSet:RouterEventType) {const{ productId } = routerPathSet.pathData;console.log('[Route Handler] Detail', { productId });return`<detail-page product-id="${productId}"/>`;
}// 404 fallback (order: 999 runs last)@subscribeSwcAppRouteChange(['/{tail:.*}'], {order:999})@innerHtmlLighthandle404(routerPathSet:RouterEventType) {console.log('[Route Handler] 404 Not Found', routerPathSet.path);return`<not-found-page/>`;
}
}interfaceSwcAppRouteChangeOptions{path?:RoutePathType;// string | string[] | (currentThis) => string | string[]order?:number;// Execution order (default: 0)filter?:(router:Router,meta: { currentThis:any; helper: HelperHostSet }) =>boolean|Promise<boolean>;before?:(router:Router, meta) =>any;// result → @routeChangeBeforeReturnfinally?:(router:Router, meta,ctx: { args; result?; error? }) =>any;valueKey?:symbol|string;// value used for the stop checktrigger?:'connected'|'connectedDone';// replay timing (see "Message Replay")}Forms:@subscribeSwcAppRouteChange(bare, all routes),('/path'),(['/a', '/b']),(path, options),(options). Alias:@changedRoute.
Usage:
@subscribeSwcAppRouteChange(
['/admin/{section}'],
{order:5,filter:(router, meta) =>meta.currentThis.isAdmin===true}
)@innerHtmlLighthandleAdminSection(routerPathSet:RouterEventType) {const{ section } = routerPathSet.pathData;return`<admin-${section}/>`;
}Stack several decorators on one method and split the returned object withvalueKey.Note:returning a value stops the later handlers, so an observer-style handler usesorder: -1and returnsundefinedto keep the chain going.
@replaceChildrenLight({valueKey:'element'})@subscribeSwcAppRouteChange('/releases')@attribute('nav','href', {valueKey:'href'})handleExplore() {return{element:ExplorePage(w),href:'releases'};// highlight nav + render the page}@subscribeSwcAppRouteChange({order: -1})@removeAttribute('nav','href')handleNavReset() {returnundefined;// reset the highlight, keep the chain going}/* Highlight the current section button */nav[href="releases"][data-href="/releases"]{color:var(--color-text);background:var(--color-bg-alt); }typeRouterEventType= {path:string;// Current route pathurl:string;search:string;searchParams:URLSearchParams;data?:any;pathData?:Record<string,any>;// Extracted path parameters (filled per matched pattern)router:Router;triggerPoint:'start'|'end'|'first-end';// Route change phase};Example with path parameters:
Route: /product/{id}
URL: /product/123
pathData: { id: '123' }When using multiple decorators on the same method, each decorator can extract its own value from the return object using its symbol key or customvalueKeyoption.
How It Works:
ATTRIBUTE_METADATA_KEY,PROPERTY_METADATA_KEY)Method 1: Using Default Symbol Keys
import{ATTRIBUTE_METADATA_KEY,PROPERTY_METADATA_KEY,STYLE_METADATA_KEY,CLASS_METADATA_KEY}from'@dooboostore/simple-web-component';@elementDefine('multi-decorator-example')classMultiDecoratorExampleextendsHTMLElement{@attribute('selector','data-state')@property('selector','value')@updateStyle@updateClasshandleUpdate() {return{
[ATTRIBUTE_METADATA_KEY]:'attribute-value',
[PROPERTY_METADATA_KEY]:'property-value',
[STYLE_METADATA_KEY]: {color:'red',fontSize:'16px'},
[CLASS_METADATA_KEY]: {'active':true,'disabled':false}
};
}
}Method 2: Using Custom valueKey Option
For more readable code, use thevalueKeyoption to specify custom keys:
@elementDefine('custom-key-example')classCustomKeyExampleextendsHTMLElement{@attribute('selector','data-state', {valueKey:'attrValue'})@property('selector','value', {valueKey:'propValue'})@updateStyle('$this', {valueKey:'styleValue'})@updateClass('$this', {root:'auto',valueKey:'classValue'})// class options need `root`handleUpdate() {return{attrValue:'attribute-value',propValue:'property-value',styleValue: {color:'red',fontSize:'16px'},classValue: {'active':true,'disabled':false}
};
}
}Method 3: Mixing Symbol Keys and Custom Keys
You can mix both approaches in the same method:
@elementDefine('mixed-keys-example')classMixedKeysExampleextendsHTMLElement{@attribute('selector','data-state')// Uses default ATTRIBUTE_METADATA_KEY@property('selector','value', {valueKey:'customProp'})// Uses custom key@updateStyle('$this', {valueKey:'styles'})// Uses custom keyhandleUpdate() {return{
[ATTRIBUTE_METADATA_KEY]:'attr-value',customProp:'prop-value',styles: {color:'blue'}
};
}
}Supported Decorators with valueKey:
@applyAttribute/@attribute@applyProperty/@property@applySlot(id, { position, valueKey })(theclearSlot/appendHtmlSlot/ ... shorthands take no options)@applyNode/@replaceChildren/@innerHtml/ etc.@applyStyle/@updateStyle/ etc.@applyClass/@updateClass/ etc.@emitCustomEvent/@emit@publishSwcAppMessage/@publishMessage@subscribeSwcAppRouteChange- the extracted value decides whether the chain stops@setInterval/@setTimeout/@requestAnimationFrame- variant: the extracted value must itself be a function, which becomes the timer/frame callback (see "Timers" above) rather than being applied directly.Each decorator will use only its corresponding value from the return object, preventing conflicts and allowing clean separation of concerns.
Factory-based component registration with explicit DI.
// Component factoryexportdefault(w:Window) => {consttagName ='my-component';constexisting = w.customElements.get(tagName);if(existing)returntagName;@elementDefine(tagName, {window: w })classMyComponentextendsw.HTMLElement{@onInitializeonconstructor(@inject(MyService.SYMBOL)service:MyService) {this.service= service;
}
}returntagName;
};// Boot factoryexportdefault(w:Window,container:symbol) => {
serviceFactories.forEach(s=>s(container));
};// Entry pointconstcontainer =Symbol('app');awaitdefineSwcAppBody(w);constappElement = w.document.querySelector('#app')asSwcAppInterface;
appElement.connect({
container,window: w,onStartedLazyDefineComponent: [...pageFactories, ...componentFactories],onEngineStarted:() =>{
appElement.innerHTML='<root-router></root-router>';
}
});Most DOM decorators take anoptional selectorthat defaults to$thiswhen omitted (or when used bare). Scope shorthands (*This,*AppHost,*Light,*Shadow,*All) are still provided for brevity.
// Single function with optional selector@applyClass('selector','update')method() { ... }@applyClass('$this','update')// a single string is treated as the selector, so name $this explicitly (or use bare @updateClass)method() { ... }@applyClass// Bare decorator - selector defaults to $thismethod() { ... }applyClass.ts:
applyClass(selector?, action?, options?)- Set/update/add/remove/toggle classessetClass(selector?, options?)- Replace all classesupdateClass(selector?, options?)- Toggle classesaddClass(selector?, options?)- Add classesremoveClass(selector?, options?)- Remove classestoggleClass(selector?, options?)- Toggle classesapplyStyle.ts:
applyStyle(selector?, action?, options?)- Set/update/remove stylessetStyle(selector?, options?)- Clear and set stylesupdateStyle(selector?, options?)- Update/merge stylesremoveStyle(selector?, options?)- Remove specific stylesapplyProperty.ts:
property(selector?, propertyKey?, options?)- field: property get/set; method: return value → propertyapplyAttribute.ts:
attribute(attrName | selector?, attrName?, options?)- field: attribute get/set; method: return value → attribute (one string = $this)query.ts:
query(selector, options?)- Query single element (supports $this, $host, $appHost, etc.;pickoption)queryAll(selector, options?)- Query multiple elements (querywithpick: 'all')emitCustomEvent.ts:
emitCustomEvent(target, type, options?)- Emit custom eventsemit(target, type, options?)- Short aliasemitCustomEvent(type, options?)- Emit from$this@elementDefine('my-component')classMyComponentextendsHTMLElement{// Class management - bare form targets $this@updateClasstoggleActive() {return{'active':this.isActive};
}// Style management - bare form targets $this@updateStyleapplyTheme() {return{color:this.theme.color,fontSize:'16px'};
}// Property binding - first string is the selector@property('input','value')updateValue() {returnthis.computedValue;
}// Attribute binding - one string = attribute on $this@attribute('data-id')updateId() {returnthis.elementId;
}// Query elements@query('#input')inputElement?:HTMLInputElement;@queryAll('li')listItems?:HTMLLIElement[];// Emit custom events@event('button','click')@emitCustomEvent('$this','item-selected')onItemSelect() {return{itemId:this.selectedId};
}// With selector and action@applyClass('.card','update')updateCardClass() {return{'highlighted':true};
}
}$this@simis forServices only. Web Components should use@elementDefine.
// ✅ CORRECT@simexportclassUserService{ }@elementDefine(tagName, {window: w })classUserWidgetextendsw.HTMLElement{ }// ❌ WRONG@sim@elementDefine(tagName, {window: w })classUserWidgetextendsw.HTMLElement{ }exportdefault(w:Window) => {consttagName ='my-element';if(w.customElements.get(tagName))returntagName;@elementDefine(tagName, {window: w })classMyElementextendsw.HTMLElement{ }returntagName;
};// ✅ CORRECT@onInitializeonconstructor(@inject(Service.SYMBOL)service:Service) {this.service= service;
}// ❌ WRONGconstructor(privateservice:Service) {super(); }// Web Components can't have constructor parameters!pnpm testtype-checks the tests and runs them in a real browser (Chromium via Playwright, falling back to the installed Chrome) — no jsdom. Each test gets its own same-origin iframe window, so custom elements,historyandlocationnever leak between tests.
pnpmtest# type checks + all testsnodetest/unit/run.mjs query event# only files whose name contains "query" / "event"SWC_TEST_GREP='bare @query'nodetest/unit/run.mjs# only tests whose name matchesSWC_TEST_DEBUG=1 nodetest/unit/run.mjs route# print the page consoleSWC_TEST_SERVE=1 nodetest/unit/run.mjs route# just serve the pages and open them yourselfTest files are plainnode:test/node:assertcode (test/unit/*.test.ts); the runner maps those imports to small in-browser versions.test/unit/empty-parens.types.tsis checked bytsconly and guards the "no arguments → no parentheses" rule.
Late subscribers can receive past messages. The buffer keeps every typed message
(SwcConfigType.messageReplayBufferSize, default 10, minimum 1 guaranteed).
// 'behavior' = last message only, 'replay' = whole buffer in order.// Unset (or 'subject') = live only.@subscribeSwcAppMessageBehavior('auth-changed')// = @subscribeSwcAppMessage('auth-changed', { subject: 'behavior' })onAuth(@swcAppMessagemsg:SwcAppMessage<User|null>) { ... }triggercontrols when replay happens (default'connected').
'connected': replay when the element registers with the app host, i.e. before its own@onConnectedBodyrender. For handlers that don't need DOM targets.'connectedDone': replay after the element finished rendering (after@onConnectedCompleted).
Use it when the handler targets DOM rendered by the element itself, like@innerHtml('.wrap')(avoids the race where replay arrives before render and gets silently dropped).@subscribeSwcAppMessage('auth-changed', {subject:'behavior',trigger:'connectedDone'})@innerHtml('.wrap')asyncload(@swcAppMessagemsg:SwcAppMessage<User|null>) { ... }Route subscribers (@subscribeSwcAppRouteChange) accept the sametrigger.
Live broadcasts always reach everyone regardless of trigger.
@swcAppMessageon fieldsOn a field,@swcAppMessageholds the latestSwcAppMessage— set on every publish, before the same element's subscribers run. It takes the same forms andsubjectrule as@subscribeSwcAppMessage:
@swcAppMessagelast?:SwcAppMessage;// every type, live only@swcAppMessage('auth-changed')auth?:SwcAppMessage<User|null>;// live only (default 'subject')@swcAppMessage('auth-changed', {subject:'behavior'})me?:SwcAppMessage<User|null>;// late element: last one@swcAppMessage({messageType:'auth-changed',subject:'replay'})all?:SwcAppMessage;// late element: buffer in order'behavior'/'replay'(and a type), an element attached after the publish gets the field filled from the buffer before its first render. Without a type nothing is replayed.@swcAppMessageBeforeReturnstays parameter-only: it is thebeforehook's result for one handler call.The three host hooks ofSwcAppMixinareabstract: a subclass must implement them (use an empty method if there is nothing to do).@injectparameters are resolved via DI (host element itself included).
onConnected()- at connectedCallback (every time it is attached). It runs one async step after attaching, so its order relative toconnect()depends on when you callconnect():
connect()right after attaching →connect()→onSwcAppConnected()→onConnected()(DI already exists)connect()later →onConnected()(no DI yet) →connect()→onSwcAppConnected()So don't rely on DI or on ordering here — keep it to DOM/attribute work and subscriptions; call services inonSwcAppConnected()
onSwcAppConnected()- afterconnect()completes (DI container and router are ready).Call services here
onDisconnected()- at disconnectedCallback. Release subscriptions created inonConnected
classMyAppBodyextendsSwcAppMixin(w.HTMLBodyElement) {onConnected() {}asynconSwcAppConnected(@inject(AuthService.SYMBOL)auth:AuthService) {this.publishMessage({type:'auth-changed',data:awaitauth.me().catch(() =>null) });
}onDisconnected() {}
}host.observeMessage()// all types, live onlyhost.observeMessage('auth-changed')// one typehost.observeMessage('auth-changed', {subject:'behavior'})// type + replay of the last valuehost.observeMessage({type:'auth-changed',subject:'replay'})// options-object formsubject:'behavior'(last one) /'replay'(whole buffer) is delivered first at subscribe time, then live messages.typeis given; options without a type receive live messages only.Observable;unsubscribe()theSubscriptionyourself (usually inonDisconnected).When publishing from the host itself, callthis.publishMessagedirectly.
The@publishSwcAppMessagedecorator publishes through the parent host, and the
host itself has no parent, so the message would be dropped.
UseconnectedElements()to get the currently connected child elements.
@fetchand its aliases (@fetchGet,@fetchPost,@fetchLatest,@fetchManual, …) go in one of three places.
Pick bywho calls itandwhether the result should be remembered:
| Where | Declare | Use when | Remembers the result? |
|---|---|---|---|
| Method | load(@fetchPromise p: Promise<T>) { … } | the framework calls it — a trigger handler (@eventClick,@onConnectedAfter, a route…) | no |
| Function field | declare getPost: (id: number) => Promise<T>; | you call it yourself, anywhere, maybe with arguments (this.getPost(1)) | no — every call is a new request |
| Value field | declare me?: Promise<T>; | data you load once and keep reading (await this.me) | yes— until you clear it |
Stack the trigger, the outputs and@fetchon one method. The request starts first; itsPromiseis handed to the@fetchPromiseparameter and the method runsright away. The method's own return value is returned unchanged, so the
signature you wrote stays true (a sync method stays sync).
@eventClick('.load')@innerHtml('.out', {fallback:() =>'Loading…'})// shown until the returned Promise settles@fetchLatest('/api/post/1')// click again → the previous request is abortedload(@fetchPromisep:Promise<Post>) {returnp.then(post=>post.title,() =>'Failed');// success / failure, no try/catch}If you also call such a method yourself, declare the parameter optional (@fetchPromise p?: Promise<T>) — the
framework fills it, callers don't. For a request you call yourself, a field (below) is the better fit.
The framework writes the function body; the declared type is the real signature (no injected parameter, no changed return type).
// GET / DELETE: the arguments feed url / request@fetchGet((_self, _helper, [id]) =>`/api/posts/${id}`)declaregetPost:(id:number) =>Promise<Post>;// POST / PUT / PATCH: the last argument is the body@fetchPost('/api/posts', {process:'json'})declarecreatePost:(body:NewPost) =>Promise<Post>;@fetchLatest({url:(_s, _h, [q]) =>`/api/search?q=${q}`})// only the latest call settlesdeclaresearch:(q:string) =>Promise<Item[]>;// use it from a trigger handler (or anywhere)@eventClick('.load')@innerHtml('.out', {fallback:() =>'Loading…'})show() {returnthis.getPost(1).then(p=>p.title);
}Every call is a new request. Success resolves, failure rejects withHttpResponseError(see below).
@fetch('/api/me')declareme?:Promise<User>;// `?` so that `this.me = undefined` type-checks under `strict`awaitthis.me;// first read: request → remembered on successawaitthis.me;// remembered result (no request)this.me=undefined;// forget it (null works too) …awaitthis.me;// … so this read requests again — this is how you reloadthis.me= savedUser;// or seed a value yourself (e.g. data that came with SSR) — no requestDeclare it optional (declare me?: Promise<T>) if you ever clear it: understrict, assigningundefinedto a
plainPromise<T>is a type error. Reading it is stillawait this.me(the result type isT | undefined).
If you never clear it,declare me: Promise<T>is fine.
Clear it by assigning, not withdelete this.me— the memory lives behind the field's setter.
Remembered: success, per element.Not remembered: failure — the next read requests again.
Forgotten: when you assignundefined/null, and when the element disconnects (the request is aborted; a read
after reconnecting requests again).
There are no arguments — build the body in therequestfactory if the request needs one.
If the data must be fresh on every use, declare a function field instead (declare loadMe: () => Promise<User>).
The value is a real Promise to your code (instanceof Promiseis true), so it can be returned straight to an output
decorator:@innerHtml('.name') showName() { return this.me.then(u => u.name); }.
Always writedeclare.Without it, builds withuseDefineForClassFields: true(the default for target ES2022+)
create an instance field that hides what the framework put there:
| Build setting | declare me: Promise<T> | me!: Promise<T>(nodeclare) |
|---|---|---|
useDefineForClassFields: false | works | works (butstrictneeds the!) |
useDefineForClassFields: true/ ES2022+ default | works | broken— reported on connect:must be declared withdeclare |
Declared type is checked.At compile time the field must be(...args) => Promise<T>orPromise<T>(string,() => T, … are errors; optional?fields are fine;private/protectedfields can't be seen by
TypeScript and are not checked).
Builds that emitdesign:type(emitDecoratorMetadatawith tsc / ts-loader) also reject a non-function, non-Promise
type at runtime.
Nothing else on the field.Triggers and outputs go on a method that calls the field. Method decorators on a field
are already compile errors; any other SWC decorator on the same field (@attribute,@query, …, or anything viaas any) throws when the class is defined.
Same in every build.Function vs. value is decided by how you declared it, not by build metadata: the field holds one object that is callable and a Promise, and TypeScript lets you use it only the way you declared it.
Quiet ends.A call skipped byfilteror made while the element is detached, and a call aborted by the framework
(disconnect,abortPrevious), return a Promise thatnever settles— nothing draws on a detached DOM, nothing throws.
trigger: 'before'): start the request → inject itsPromisevia@fetchPromise→ run the methodright away. Returns the method's own return value, so the
signature you wrote stays true (sync stays sync). Success resolves, failure rejects — handle both withthen/catch/finallyon the Promise.
If you also call the method yourself, declare the parameter optional (@fetchPromise p?: Promise<T>) —
the framework fills it, callers don't.trigger: 'after', or justvalueKey): run the method → its return value
(orreturnValue[valueKey]) becomes the body → fetch. Returns the fetch result; a failed fetch throws.valueKeyis after-only — combining it withtrigger: 'before'is a type error.Declarative vs manual— pick one:
url/request/processand the framework fetches.manualand make the request yourself (a service call, your ownfetch, ...).url/request/processare unused then, so combining them withmanualis a type error.// service call — only the result matters@fetch({manual:(self) =>self.myService.getProfile({}) })load(@fetchPromisep:Promise<MyService.ProfileResponse>) { ... }// your own fetch — pass the signal so an abort cancels the network request too@fetch({trigger:'after',manual:(_self, _helper, _params, signal, returnValue) =>window.fetch('/api/save', {method:'POST',body:JSON.stringify(returnValue), signal }).then(r=>r.json()) })asyncsave() {return{title:'hi'}; }manual(this, helper, params, signal, returnValue?)—signalaborts on disconnect /abortPrevious;returnValueis the method's return value (orreturnValue[valueKey]) in after mode,undefinedin before mode.
Its return value is the fetch result.
Options (declarative)
url: a string/URL/Request, or a factory(this, helper, params) => url.request: an HTTP method string ('GET'/'POST'/...) or a factory returningRequestInit—(this, helper, params) => RequestInitin before mode,(this, helper, params, returnValue) => RequestInitin after mode. Absent = GET, or POST when there is a body.process: body encoding for plain values —'json'/'form'(multipart) /'urlencoded'/'text'.
Values that already are aBodyInit(string,Blob,ArrayBuffer,FormData,URLSearchParams,ReadableStream) are sent as-is. Unset = pass through untouched.requestmay returnheadersas an object, array orHeadersinstance. AContent-Typeyou set wins over the oneprocesswould add.fetch(the per-request window under SSR), falling back toglobalThis.fetch.undefined, JSON content types (application/json,*+json) → parsed, otherwise text. A non-OK status throwsHttpResponseErrorfrom@dooboostore/corewithresponse(status, headers) and the parsedbody:@fetch('/api/post/1')load(@fetchPromisep:Promise<Post>) {returnp.catch(e=>isHttpResponseError(e) ? e.response?.status:undefined);// e.g. 404}filter— the only hook
filter({ self, helper, args })→falseskips the whole call: no request, no method. A method returnsundefined;
a field returns a Promise that never settles.
It is synchronous: the request starts before the method runs, so "send or not" can only be decided here.before/after/finallyon@fetch: result handling is plain Promise code
(then/catch/finally), and gating or cleanup around the call lives on the trigger you stack it with
(@eventClick,@onConnectedAfter, …), which already hasfilter → before → handler → finally.Cancellation
AbortController. Declarative requests get its signal asinit.signal(merged with asignalyou return fromrequest, viaAbortSignal.anyor a fallback);manualgets it as
thesignalargument.@fetchrequests are aborted. The injected Promise thennever settles(before mode), so nothing draws on a detached DOM and nothing throws; an after-mode call
ends quietly withundefined.setTimeoutscheduled just before the user
navigated away) is skipped the same quiet way. Elements that were never connected are not affected.manualshould passsignalon to its own client (fetch, axios, ...) so the network
request is actually cancelled. If it ignores the signal, the call still ends as soon as it's aborted
(the result is raced against the abort) — the request just keeps running in the background and its
late result is discarded.abortPrevious: trueaborts the previous in-flight call of the same method (per element) when it's called again
(search-as-you-type) — the superseded Promise never settles, so a late old response can't
overwrite the newest result.Aliases
| Alias | Same as |
|---|---|
@fetchManual(fn, options?) | @fetch({ ...options, manual: fn }) |
@fetchGet(url, options?)/@fetchDelete(url, options?) | before mode,methodfixed to GET / DELETE |
@fetchPost(url, options?)/@fetchPut(...)/@fetchPatch(...) | after mode (the method's return value is the body),methodfixed |
@fetchLatest(url | options) | @fetch({ ...options, abortPrevious: true }) |
@fetchBefore(options)/@fetchAfter(options) | @fetch({ ...options, trigger }) |
For the method aliases,requestmay only be a factory returningRequestInit(e.g. headers) — itsmethodis overwritten.
@fetchManual((self) =>self.myService.getProfile({}))load(@fetchPromisep:Promise<Profile>) { ... }@fetchPost('/api/posts', {process:'json'})asyncsave() {return{title:'hi'}; }@fetchDelete((_self, _helper, params) =>`/api/posts/${params[0]}`)remove(id:number,@fetchPromisep?:Promise<unknown>) { ... }Composition— e.g. form submit:@event('form', 'submit', { before: (e) => new FormData(e.target), preventDefault: true })stacked on@fetch({ url: '/api/echo', trigger: 'after', process: 'form' }), with the method taking@eventBeforeReturn formData: FormDataand returning it.
Unlike@hostSet/@helperSet/@helperHostSet(which just hand you context objects), these decorators activelygo compute or fetch a valueand inject it — once, at the moment the method's trigger fires (@onConnectedAfterand friends,@addEventListener,@setInterval/@setTimeout, the observer decorators,@eventMedia, route/message subscribers, ...). They only work inside such auto-triggered methods — a plain method you call yourself getsundefinedfor these parameters, since nothing builds the lookup for it.
Unlike
@query/@queryAll(lazy — re-queries the DOM on every read), these resolveonce per call, as a plain captured value, not a live reference. If the DOM/storage changes after the method runs, the parameter you already received does not change.
Same engine as the field decorators@query/@queryAll(root,pick,filter, function selectors all supported) — named "querySelector" instead of "query" so it doesn't read like a URL query parameter.
@onConnectedAfteronReady(@querySelectorParam('#name')input:HTMLInputElement,@querySelectorAllParam('.item', { root:'all'})items:HTMLElement[]) {
input.focus();
}@addEventListener('#save','click')onSave(@querySelectorShadowParam('.form')form:HTMLFormElement) { ... }@query's own family:querySelectorShadowParam/querySelectorLightParam/querySelectorAllRootsParam(single),querySelectorAllShadowParam/querySelectorAllLightParam/querySelectorAllAllParam(arrays).Same engine as the field/method@attribute.
onReady(@attributeParam('data-x')ownAttr:string,@attributeParam('#child','data-y', { root:'shadow'})childAttr:string) { ... }(selector, name, options?)= another element's attribute (same two-form shape as the field decorator).options.typeconverts toNumber/Boolean, same as the field decorator; missing attribute/target →null.Inject a persisted value by key (see "Persisted State" below for the backing storage).@indexedDbParamis async, so it injects aPromiserather than the resolved value — the surrounding resolution has to stay synchronous:
onReady(@localStorageParam('theme')theme:string,@indexedDbParam('draft')draftPromise:Promise<string>) {this.applyTheme(theme);
draftPromise.then(d=>this.restoreDraft(d));
}A minimal fetch — no abort-on-disconnect, noabortPrevious, nofilter(reach for@fetch/@fetchManual+@fetchPromisewhen you need those). It injects aPromisethat resolves to the parsed JSON/text body, or rejects with anErroron a non-OK status.
@onConnectedAfterasynconReady(@fetchParam('/api/products/1')p:Promise<Product>) {this.product=awaitp;
}// url/init accept (currentThis, helper) => ... callbacks too, same shape as @fetch's factories —// handy for referencing instance fields to build a dynamic URL or body.@addEventListener('#save','click')asynconSave(@fetchParam((self) =>`/api/products/${self.productId}`,(self) =>({method:'POST',body:JSON.stringify({qty: self.qty}) })
)p:Promise<any>) {awaitp;
}Documented above alongside@hostSet/@helperSet— these just hand you a context reference (theRouter, the DI container, or theSwcApphost element itself), they don't compute anything the way the rest of this section's decorators do.
Field or method decorator that mirrors a value to a storage backend. Survives disconnect/reconnect and page reloads — the whole point is thatelement lifecycle never clears it; only you do (see "Removing a key" below).
classSettingsextendsHTMLElement{// Field — bare key defaults to the field name; the stored value wins over the initializer on reconnect@localStoragetheme:string='dark';// Explicit key@sessionStorage('draft-id')draftId?:string;@cookie('session-token', {maxAge:3600,sameSite:'Strict'})token?:string;// Method — persists the return value (JSON-serialized) each time it runs@localStorage('last-search')search(q:string) {returnq; }
}undefinedremovesthe key (and clears the in-memory value too).valueKeypicks one field out of an object return).local/session/cookie(sync) andindexeddb(async). The generic form is@persistState(key?, { storage, ... });@localStorage/@sessionStorage/@cookie/@indexedDbare storage-fixed aliases.@indexedDbon a field: reading can'tawait, so the first read returns the initializer immediately and the stored value lands right after (a console warning flags this once per class) — prefer callingpersistReadAsyncdirectly, or aPromise-typed field, for async backends.valueKey,serialize/deserialize(defaultJSON.stringify/JSON.parse);cookiealso takespath/domain/maxAge/expires/secure/sameSite.Removing a key
classSettingsextendsHTMLElement{@localStoragetheme:string='dark';@removeLocalStorage('theme')clearTheme() {}
}@removeLocalStorage/@removeSessionStorage/@removeCookie/@removeIndexedDb(method-only) — call them, or just assignundefinedto the field directly (this.theme = undefined), which does the same thing and also clears the live value.@removePersistState(key?, options?).Direct function access(no decorator needed) — for a plain method body, or the async backend:
import{ persistRead, persistWrite, persistRemove, persistReadAsync, persistWriteAsync, persistRemoveAsync }from'@dooboostore/simple-web-component';constcached =persistRead(this,'theme', {storage:'local'});constdraft =awaitpersistReadAsync(this,'draft', {storage:'indexeddb'});@property(no args) to a field to mark it as an SSR hydration target.$this+ matching key) stay plain fields with no getter/setter.declare(both= nulland;initializers get overwritten on upgrade).