@dooboostore/simple-web-component (SWC)

NPM versionBuild and TestLicense: MIT

SWCis a lightweight, production-ready Web Components framework for building fast, modular, and maintainable SPAs without Virtual DOM overhead.


🎯 Core Features

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 })).

1.@elementDefine- Component Registration

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…

  • hooks on both run parent first, then child
  • a subclass's own decorators never leak into the base class or into sibling subclasses
  • a subclass of a class that is itself@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>

2.Dependency Injection (@onInitialize)

Inject services into Web Components using the@onInitializedecorator.

@elementDefine('dashboard')classDashboardextendsHTMLElement{@onInitializeonconstructor(@inject(UserService.SYMBOL)userService:UserService) {this.userService= userService;
  }
}

3.Declarative DOM Updates

@onConnectedBodyShadow

Render HTML automatically when component connects to DOM.

@onConnectedBodyShadowrender() {return`<div>Hello, <span>${this.name}</span>!</div>`;
}

@innerHtmlLight

Apply HTML to specific host element.

@innerHtmlLightupdateContent() {return`<p>Updated content</p>`;
}

@replaceChildrenLight

Replace child nodes with new content.

@replaceChildrenLightrenderNode() {returnthis.buildNode();// Node, Node[] or HTML string}

3.5Slot Management with @applySlot

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 content
  • prependHtmlSlot(slotId)- Add HTML at beginning
  • prependTextSlot(slotId)- Add text at beginning
  • appendHtmlSlot(slotId)- Add HTML at end
  • appendTextSlot(slotId)- Add text at end
  • replaceChildrenHtmlSlot(slotId)- Replace all with HTML
  • replaceChildrenTextSlot(slotId)- Replace all with text

Position 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 content

3.6State Management with @state

Reactive 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 variables

Template Directives:

  • <!--[html @state@ ]-->- Render state value as HTML
  • <!--[text @state@ ]-->- Render state value as text
  • a::attributeName="@state@"- Bind state to HTML attributes
  • e::eventName="@methodName@()"- Bind events to methods

How @state Works:

  1. Declare property with@state('stateName')
  2. When property value changes viathis.count++, setter is triggered
  3. Setter automatically applies state context to templates
  4. All@stateName@expressions in templates are re-evaluated
  5. DOM updates automatically with new values

State Context Available:

  • @propertyName@- Access state properties
  • @methodName@()- Call methods
  • @expression@- Evaluate JavaScript expressions
  • All state variables available in template expressions

Important: 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>
    `;
  }
}

4.Event Handling

@event

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 form
  • event(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 both
  • eventAppHost(type, options)- Listen on $appHost
  • eventWindow(type, options)- Listen on window
  • eventDocument(type, options)- Listen on document
  • eventDelegate(selector, type, options)- Event delegation
  • eventDelegateLight(selector, type, options)- Delegate in light DOM
  • eventDelegateShadow(selector, type, options)- Delegate in shadow DOM
  • eventDelegateAll(selector, type, options)- Delegate in all DOM
  • eventLight(selector, type, options)- Bind (non-delegated) in light DOM
  • eventShadow(selector, type, options)- Bind (non-delegated) in shadow DOM
  • eventAll(selector, type, options)- Bind (non-delegated) in both light & shadow DOM
  • eventMutation(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 options
  • preventDefault/stopPropagation/stopImmediatePropagation- applied before the handler runs
  • filter: (event, { currentThis, helper }) => boolean | Promise<boolean>- skip the handler when it returns false
  • before: (event, meta, args) => any- runs afterfilter; its return value is injectable with@eventBeforeReturn
  • finally: (event, meta, { args, result, error }) => any- always runs after the handler
  • debounceTime/throttleTime(ms) /distinctUntilChanged- rate-limit the event stream
  • removeListener: (target, options) => void- cleanup callback on disconnect

Event-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.

Order-Independent Parameter Decorators (@eventObject, @matchedElement, @hostSet, @helperHostSet, @helperSet)

@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.
  • DOM/storage/fetch values—@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) { ... }

@emitCustomEvent

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 form
  • emit(target, type, options)- Short alias
  • emitCustomEvent(type, options)- Without a selector it emits from the element itself (emitAppHost/emitWindow/emitDocumenttake(type, options);emitLight/emitShadow/emitAlltake(selector, type, options))
  • Options:bubbles,composed,cancelable,filter,valueKey,attributeName,root
  • valueKey: 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 object

@publishSwcAppMessage

Publish 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 type
  • publishSwcAppMessage(messageType)- Publish with specific message type
  • publishSwcAppMessage(messageType, { valueKey: 'customKey' })- Publish with custom value extraction
  • publishSwcAppMessage({ messageType: 'type', valueKey: 'customKey' })- Publish with options object
  • publishMessage- Short alias
  • publishMessage(messageType)- Short alias with message type
  • publishMessage(messageType, options)- Short alias with options

Using valueKey for Multiple Decorators:

@publishSwcAppMessage('event1', {valueKey:'detail1'})@publishSwcAppMessage('event2', {valueKey:'detail2'})handleMultipleEvents() {return{detail1: {type:'event1',data:'value1'},detail2: {type:'event2',data:'value2'}
  };
}

Emitting from the element itself

@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>

Listening on the element itself

@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');
}

@eventAppHost

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');
}

5.DOM Querying

@query

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;

@queryAll

Query multiple elements.

@queryAll('input[type="text"]')textInputs?:HTMLInputElement[];@queryAll('li', {root:'shadow'})listItems?:HTMLLIElement[];

Special selectors,pick, and shorthands

Special 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[];
  • Shorthands:queryLight/queryShadow/queryAllRoots(single),queryAllLight/queryAllShadow/queryAllAll(arrays)
  • filter: (el, { currentThis, helper }) => booleannarrows the matches
  • Assigningnull/undefined/[]to a@queryfield removes the matched elements from the DOM

6.Attribute Binding

@attribute (Field / Method Decorator)

Works on both fields and methods; behavior depends on where it is applied.

  • Field: getter/setter that reads/writes the element attribute (typeconverts to Number/Boolean)
  • Method: applies the return value to the attribute.nullremovesit,undefineddoes nothing

One 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 object
  • Options:type,filter,valueKey,root. Shorthands:attrAppHost,attrLight,attrShadow,attrAll(for $this just give the name:@attribute('name'))
  • A field getter evaluates a{{= expr }}attribute value (see "Attribute Expressions")

@removeAttribute (Method Decorator)

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}

@changedAttribute (Method Decorator)

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, orString
  • while-'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)

6.5Property Binding

@property (Field / Method Decorator)

Works on both fields and methods.

  • Field: getter/setter that reads/writes a property of the target element
    • Bare@property(=$this+ same name) stays a plain field with no getter/setter and is only registered as anSSR hydration target
  • Method: assigns the return value to the target element's property (undefinedis 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
  • To call a method on the target, use@callProperty('selector', 'methodName')(an array return value is spread as arguments;callPropertyLight/callPropertyShadow/callPropertyAllalso exist)

6.6DOM Observers (@mutationObserver, @resizeObserver, @intersectionObserver)

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.

@mutationObserver

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 inmutations
  • mutations: 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:

  • delegate=true- Observes the root withsubtree: true; dynamically added/removed elements matching the selector are detected in the callback
  • delegate=false(default) - Observes only the elements matching the selector at connect time

@mutationObserver Options:

  • StandardMutationObserverInitoptions:childList,attributes,characterData,subtree,attributeFilter,attributeOldValue,characterDataOldValue
  • delegate- Observe root and filter by selector in callback
  • root-'light' | 'shadow' | 'all' | 'auto'
  • filter- Additional callback filter
  • removeObserver- Cleanup callback called on disconnect(target, options)

@mutationObserver Decorator Variants:

  • mutationObserver(selector?, options?)- Full form (selector defaults to$this)
  • mutationObserverLight(selector?, options?)- Observe light DOM
  • mutationObserverShadow(selector?, options?)- Observe shadow DOM
  • mutationObserverAll(selector?, options?)- Observe both light & shadow
  • mutationObserverDelegate(selector?, options?)- Delegate mode (root observe)
  • mutationObserverDelegateLight(selector?, options?)- Delegate in light DOM
  • mutationObserverDelegateShadow(selector?, options?)- Delegate in shadow DOM
  • mutationObserverDelegateAll(selector?, options?)- Delegate in all DOM

@resizeObserver

Detect 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 elements
  • root-'light' | 'shadow' | 'all' | 'auto'
  • filter- Additional callback filter
  • removeObserver- Cleanup callback called on disconnect(target, options)

@resizeObserver Decorator Variants:

  • resizeObserver(selector?, options?)- Full form (selector defaults to$this)
  • resizeObserverLight(selector?, options?)- Observe light DOM
  • resizeObserverShadow(selector?, options?)- Observe shadow DOM
  • resizeObserverAll(selector?, options?)- Observe both light & shadow
  • resizeObserverDelegate(selector?, options?)- Delegate mode (root observe)
  • resizeObserverDelegateLight(selector?, options?)- Delegate in light DOM
  • resizeObserverDelegateShadow(selector?, options?)- Delegate in shadow DOM
  • resizeObserverDelegateAll(selector?, options?)- Delegate in all DOM

Combining 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.

@intersectionObserver

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:

  • StandardIntersectionObserverInitoptions: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 filter
  • removeObserver- Cleanup callback called on disconnect(target, options)

@intersectionObserver Decorator Variants:

  • intersectionObserver(selector?, options?)- Full form (selector defaults to$this)
  • intersectionObserverLight(selector?, options?)- Observe light DOM
  • intersectionObserverShadow(selector?, options?)- Observe shadow DOM
  • intersectionObserverAll(selector?, options?)- Observe both light & shadow
  • intersectionObserverDelegate(selector?, options?)- Delegate mode (root observe)
  • intersectionObserverDelegateLight(selector?, options?)- Delegate in light DOM
  • intersectionObserverDelegateShadow(selector?, options?)- Delegate in shadow DOM
  • intersectionObserverDelegateAll(selector?, options?)- Delegate in all DOM

Cleanup:All three observers are automatically disconnected when the component is removed from the DOM.removeObservercallbacks (if provided) are invoked during cleanup.

7.DOM Manipulation with applyNode

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}; }
  • Options: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 children

8.Style & Class Management

Apply 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 styles
  • updateStyle- Update/merge styles
  • removeStyle- Remove specific styles
  • applyStyle(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 classes
  • updateClass- Toggle classes by a{ className: boolean }map
  • addClass/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)
  • Note: for the class decorators, an options object in the 2nd position must containroot(e.g.{ root: 'auto', valueKey: 'k' }); an object withoutrootis read as a class map

8.5Lifecycle Hooks

Lifecycle 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):

  1. @onInitialize- once, on first init (DI via@injectparameters)
  2. @onConnectedSwcApp- the element registers with its app host (deferred untilconnect()if needed)
  3. @onConnectedBefore- before rendering
  4. @onConnectedBody(Shadow/Light) - render the returned HTML/Node (skipped for SSR-rendered elements)
  5. Observers / timers / listeners are wired up
  6. @onConnectedAfter- after rendering
  7. @onConnectedCompleted- always runs last (in afinallyblock), thentrigger: 'connectedDone'replays fire

Disconnect:@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() { }

8.6Timers (@setInterval, @setTimeout, @requestAnimationFrame)

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}

8.7@around (AOP) and @eventMedia

@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; }

9.Attribute Expressions ({{= }})

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>
  • Evaluated when the value is read by an@attributefield getter and before it is passed to@changedAttributehandlers
  • thisis 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,$qi
  • The result keeps its JavaScript type (thentypeconversion applies); on error the raw string is used

Template bindings inside@onConnectedBody*HTML (@state@,<!--[html ]-->,<!--[text ]-->,a::,e::, andp::for DOM properties) are described in the@statesection.


10.Component Communication - Message Bus

SWC provides a powerful message bus system for inter-component communication through SwcApp. Components can publish and subscribe to typed messages while connected.

@subscribeSwcAppMessage

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.

@publishSwcAppMessage

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 type
  • publishSwcAppMessage(messageType, { valueKey: 'customKey' })- Publish with custom value extraction
  • publishSwcAppMessage({ messageType: 'type', valueKey: 'customKey' })- Publish with options object
  • publishMessage- Short alias
  • publishMessage(messageType)- Short alias with message type
  • publishMessage(messageType, options)- Short alias with options

Using 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:

  • ✅Lifecycle-aware- subscriptions only active while component is connected
  • ✅Type-safe- specify message types for filtering
  • ✅Filter support- use custom filter functions to handle specific conditions
  • ✅Auto-publishing- return value automatically becomes message payload
  • ✅Async support- works with async methods (promises)
  • ✅Centralized- all messages routed through the nearest SwcApp host ($appHost)
  • ✅Decoupled- components don't need to know about each other

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);
  }
}

11.SwcApp - Multiple Element Types with Mixin Pattern

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.

Available SwcApp Variants

// 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';

Using SwcAppBody (Recommended for SPAs)

<!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().

Factory Pattern - Component Registration

// 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>';
  }
});

12.Routing with @subscribeSwcAppRouteChange

SWC provides declarative routing with automatic path matching, order-based execution, and optional propagation control.

Basic Routing Setup

@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/>`;
  }
}

Route Handler Features

1. Path Matching

  • No path pattern(omit path):@subscribeSwcAppRouteChange({ order: -1 })- matches all routes
  • Patterns are matched against thewholepath (anchored regex); a string, an array (first match wins), or a function(currentThis) => string | string[]
  • Exact match:['', '/']- matches home route only;['/products']matches/productsonly
  • Sub paths:['/products/{tail:.*}']- use a regex segment to match/products/...
  • Dynamic segments:['/product/{id}']- capturesid([^/]+); custom regex:{slug:[a-z0-9-]+}
  • Wildcard:['/{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

  • Return a value(anything other thanundefined/null, aftervalueKeyextraction) → stops this element's remaining handlers
  • Returnundefined/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)

onRuns 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)ValuePath (/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'] }
@swcAppRouteURLSearchParamsURLSearchParams
@subscribeSwcAppRouteChange('/releases/{releaseSeq}')load(@swcAppRoutePathVariable('releaseSeq')seq:string,@swcAppRouteQueryParam('tab')tab:string|null) { ... }
  • Add@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.
  • A field is reassigned only when its value changes (object fields keep their reference otherwise), so a setter on it doesn't fire for unrelated route changes.
  • Don't name such fields after built-inHTMLElementproperties (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 numberhistory move (-1= back)
undefined/null/falseno navigation (conditional)
  • Other Router methods:@swcAppRoute({ type: 'push' })or the alias with the type fixed (@swcAppRoutePush/@swcAppRoutePush({ state, valueKey })):
AliastypeReturn value
@swcAppRouteGogo(default)see the table above
@swcAppRoutePush/@swcAppRouteReplacepush/replaceRouteAction
@swcAppRoutePushUpsertSearchParam/@swcAppRouteReplaceUpsertSearchParampushUpsertSearchParam/replaceUpsertSearchParam{ key: value | value[] }
@swcAppRoutePushAddSearchParam/@swcAppRouteReplaceAddSearchParampushAddSearchParam/replaceAddSearchParam[[key, value], ...]
@swcAppRoutePushDeleteSearchParam/@swcAppRouteReplaceDeleteSearchParampushDeleteSearchParam/replaceDeleteSearchParamstring | string[]
@swcAppRoutePushDeleteHashSearchParam/@swcAppRouteReplaceDeleteHashSearchParampushDeleteHashSearchParam/replaceDeleteHashSearchParamstring | string[]

push/replacego throughbeforeLeaveguards likego. The search-param methods keep the path, so they don't count as leaving and skip the guards.

  • Options:@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 }).
  • The original return value still passes through, and navigation goes throughrouter.go, sobeforeLeaveguards apply.

Advanced Example with Logging

@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/>`;
  }
}

Router Configuration Options

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}/>`;
}

Combined Example: Route + Attribute (nav highlight)

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); }

RouterEventType Structure

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' }

Multiple Decorators with Shared Return Value

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:

  1. Each decorator has a default symbol key (e.g.,ATTRIBUTE_METADATA_KEY,PROPERTY_METADATA_KEY)
  2. When a method returns an object, each decorator checks if its key exists in the object
  3. If the key exists, the decorator uses that value; otherwise uses the entire return value
  4. This allows multiple decorators to extract different values from the same return object

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.


13.Accommodation Pattern

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>';
  }
});

🔄 Decorator API - Consolidated Pattern (v1.0.43+)

Simplified Decorator API

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.

Current Usage Pattern

// 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() { ... }

Decorator Functions (Current API)

applyClass.ts:

  • applyClass(selector?, action?, options?)- Set/update/add/remove/toggle classes
  • setClass(selector?, options?)- Replace all classes
  • updateClass(selector?, options?)- Toggle classes
  • addClass(selector?, options?)- Add classes
  • removeClass(selector?, options?)- Remove classes
  • toggleClass(selector?, options?)- Toggle classes

applyStyle.ts:

  • applyStyle(selector?, action?, options?)- Set/update/remove styles
  • setStyle(selector?, options?)- Clear and set styles
  • updateStyle(selector?, options?)- Update/merge styles
  • removeStyle(selector?, options?)- Remove specific styles

applyProperty.ts:

  • property(selector?, propertyKey?, options?)- field: property get/set; method: return value → property

applyAttribute.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 events
  • emit(target, type, options?)- Short alias
  • emitCustomEvent(type, options?)- Emit from$this

Usage Examples

@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};
  }
}

Key Features

  1. Optional Selector- Omit the selector (or use the bare form) to target$this
  2. Cleaner Code- Single function name with multiple overloads
  3. Better Discoverability- Intuitive parameter patterns
  4. Flexible Usage- Supports bare decorators, with options, and with selectors
  5. Type Safe- Full TypeScript support with proper type inference

⚠️ Critical Rules

DO NOT Use @Sim on Web Components

@simis forServices only. Web Components should use@elementDefine.

// ✅ CORRECT@simexportclassUserService{ }@elementDefine(tagName, {window: w })classUserWidgetextendsw.HTMLElement{ }// ❌ WRONG@sim@elementDefine(tagName, {window: w })classUserWidgetextendsw.HTMLElement{ }

Factory Always Returns (the tag name)

exportdefault(w:Window) => {consttagName ='my-element';if(w.customElements.get(tagName))returntagName;@elementDefine(tagName, {window: w })classMyElementextendsw.HTMLElement{ }returntagName;
};

@onInitialize for DI (Not Constructor Parameters)

// ✅ CORRECT@onInitializeonconstructor(@inject(Service.SYMBOL)service:Service) {this.service= service;
}// ❌ WRONGconstructor(privateservice:Service) {super(); }// Web Components can't have constructor parameters!

🧪 Testing

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 yourself

Test 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.

📚 Examples


🔗 Related Packages


📄 License

MIT License



📨 Message Replay (subject / trigger)

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 fields

On 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
  • With'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.

🔌 App Host Hooks (onConnected / onSwcAppConnected / onDisconnected)

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() {}
}

observeMessage — the message bus as an Observable

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 form
  • subject:'behavior'(last one) /'replay'(whole buffer) is delivered first at subscribe time, then live messages.
  • Replay only works whentypeis given; options without a type receive live messages only.
  • Returns anObservable;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.

🌐 @fetch — Declarative HTTP

@fetchand its aliases (@fetchGet,@fetchPost,@fetchLatest,@fetchManual, …) go in one of three places. Pick bywho calls itandwhether the result should be remembered:

WhereDeclareUse whenRemembers the result?
Methodload(@fetchPromise p: Promise<T>) { … }the framework calls it — a trigger handler (@eventClick,@onConnectedAfter, a route…)no
Function fielddeclare getPost: (id: number) => Promise<T>;you call it yourself, anywhere, maybe with arguments (this.getPost(1))no — every call is a new request
Value fielddeclare me?: Promise<T>;data you load once and keep reading (await this.me)yes— until you clear it

1. On a method — trigger handlers

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.

2. Function field — a request function you call

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).

3. Value field — data that is remembered

@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 request
  • Declare 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); }.

Field rules (function and value fields)

  • Always writedeclare.Without it, builds withuseDefineForClassFields: true(the default for target ES2022+) create an instance field that hides what the framework put there:

    Build settingdeclare me: Promise<T>me!: Promise<T>(nodeclare)
    useDefineForClassFields: falseworksworks (butstrictneeds the!)
    useDefineForClassFields: true/ ES2022+ defaultworksbroken— 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.

Method modes

  • before(default, ortrigger: '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.
  • after(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:

  • Declarative: describe the request withurl/request/processand the framework fetches.
  • Manual: givemanualand 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.
  • Headers:requestmay returnheadersas an object, array orHeadersinstance. AContent-Typeyou set wins over the oneprocesswould add.
  • The request uses the element's windowfetch(the per-request window under SSR), falling back toglobalThis.fetch.
  • Default response handling: empty body (e.g. 204) →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.
  • There is nobefore/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

  • Every call gets its ownAbortController. Declarative requests get its signal asinit.signal(merged with asignalyou return fromrequest, viaAbortSignal.anyor a fallback);manualgets it as thesignalargument.
  • When the element disconnects, all its in-flight@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.
  • A call thatstartswhile the element is detached (e.g. asetTimeoutscheduled 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.
  • Aborting through your own signal is not quiet: it rejects the Promise (before mode) or throws (after mode), like any other failure.

Aliases

AliasSame 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.

🧩 Value-Injecting Parameter Decorators (@querySelectorParam, @attributeParam, @local/session/cookie/indexedDbParam, @fetchParam, @swcAppRouter)

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.

@querySelectorParam / @querySelectorAllParam

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) { ... }
  • Root/pick shorthands mirror@query's own family:querySelectorShadowParam/querySelectorLightParam/querySelectorAllRootsParam(single),querySelectorAllShadowParam/querySelectorAllLightParam/querySelectorAllAllParam(arrays).

@attributeParam

Same engine as the field/method@attribute.

onReady(@attributeParam('data-x')ownAttr:string,@attributeParam('#child','data-y', { root:'shadow'})childAttr:string) { ... }
  • One string = this element's own attribute;(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.

@localStorageParam / @sessionStorageParam / @cookieParam / @indexedDbParam

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));
}

@fetchParam

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;
}

@swcAppRouter / @swcAppSimpleApplication / @swcAppHost

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.

💾 Persisted State (@localStorage, @sessionStorage, @cookie, @indexedDb, @persistState)

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; }
}
  • Field: getter/setter. Reading re-hydrates from storage on first access; assigning writes through. Assigningundefinedremovesthe key (and clears the in-memory value too).
  • Method: the return value is persisted after the method runs (valueKeypicks one field out of an object return).
  • Backends: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.
  • Options: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.
  • Generic form:@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 Hydration Rules

  • Attach bare@property(no args) to a field to mark it as an SSR hydration target.
  • Self fields ($this+ matching key) stay plain fields with no getter/setter.
  • Declare the field withdeclare(both= nulland;initializers get overwritten on upgrade).