${this.title}
+ ${keyed(this.id, html``)} + ${guard([this.id], () => html`guarded
`)} + ${cache(html``)} + +${until(new Promise(() => {}), 'fallback-only')}
+diff --git a/AGENTS.md b/AGENTS.md index ef0a03ad..526e5417 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -102,6 +102,8 @@ See `agent-docs/framework-dev.md` for monorepo commands, workspace layout, refer An **AI-first, web-components-first** framework inspired by NextJs, Lit, and Rails. +**Why lit-style web components specifically?** AI coding agents have substantial training data on lit. Aligning webjs's component runtime API (reactive properties via `static properties`, lifecycle hooks like `shouldUpdate` / `willUpdate` / `updated` / `firstUpdated` / `updateComplete`, ReactiveController hooks `hostConnected` / `hostDisconnected` / `hostUpdate` / `hostUpdated`, the full `lit-html` directive set, `html` / `css` tagged templates) lets agents emit idiomatic webjs code without framework-specific translation. Webjs ships its own implementation under `packages/core/src/` (clean JSDoc-typed JS, no-build), but the public API surface matches lit so the ecosystem's collective lit knowledge transfers directly. Decorators are the one exception (banned by invariant 10, non-erasable TS); the `declare` + `static properties` pattern replaces them. + - **Sensible defaults, overridable.** Memory store in dev, Redis when configured. HTTP caching via standard `Cache-Control`. - **Built-in essentials.** Auth, sessions, caching, cache store, rate limiting, all with pluggable adapters. - **No build step.** Source files are served as native ES modules. @@ -206,17 +208,22 @@ import { html, css, WebComponent, render, renderToString } from '@webjskit/core' ### Directives, from `import { … } from '@webjskit/core/directives'` -**"Less is more":** only directives that solve problems with no native alternative. +lit-html parity. AI agents writing lit-shaped directive code land on familiar names. | Directive | Purpose | Example | |---|---|---| -| `repeat(items, keyFn, templateFn)` | Keyed reconciliation | `${repeat(items, i => i.id, i => html\`…\`)}` | +| `repeat(items, keyFn, templateFn)` | Keyed list reconciliation | `${repeat(items, i => i.id, i => html\`…\`)}` | | `unsafeHTML(str)` | Render trusted raw HTML. **NEVER use with user input.** | `${unsafeHTML(markdownToHtml(md))}` | | `live(value)` | Input value sync against live DOM | `.value=${live(inputVal)}` | +| `keyed(key, template)` | Force remount on key change | `${keyed(this.userId, html\`
\`)}` | +| `guard(deps, fn)` | Memoize sub-template; client skips re-eval when deps unchanged | `${guard([this.title], () => html\`Loading…
\`)}` | +| `asyncAppend(iter, mapper?)`, `asyncReplace(iter, mapper?)` | Stream values from an AsyncIterable. Iteration aborts on teardown. | `${asyncAppend(stream, (v, i) => html\`Reactive controllers are a composition pattern for sharing lifecycle-bound logic across components without using inheritance. Instead of building mixin chains or base class hierarchies, you create standalone controller objects that hook into any component's lifecycle.
+Why the lit-shaped hook names? webjs adopts lit's hostConnected / hostDisconnected / hostUpdate / hostUpdated protocol verbatim because AI coding agents have substantial training data on lit. Matching lit's API names means agents emit idiomatic webjs code without framework-specific translation, and any lit ReactiveController found in the wild is drop-in compatible here.
Consider a scenario where three different components all need to fetch data on connect, poll on an interval, and clean up on disconnect. Without controllers, your options are:
@@ -22,10 +24,10 @@ export default function Controllers() {A controller is any object that implements some or all of these methods:
connectedCallback fires. Set up subscriptions, timers, and event listeners here.disconnectedCallback fires. Clean up resources.render() method. Pre-render controller logic.render() method, before firstUpdated(). Post-render controller logic.connectedCallback fires. Set up subscriptions, timers, and event listeners here.disconnectedCallback fires. Clean up resources.render() method. Pre-render controller logic.render() method, before firstUpdated(). Post-render controller logic.All methods are optional. Implement only the ones your controller needs.
@@ -42,7 +44,7 @@ export default function Controllers() { host.addController(this); // register with the host } - onMount() { + hostConnected() { this._observer = new IntersectionObserver( ([entry]) => { this.isVisible = entry.isIntersecting; @@ -53,7 +55,7 @@ export default function Controllers() { this._observer.observe(this.host); } - onUnmount() { + hostDisconnected() { this._observer?.disconnect(); this._observer = null; } @@ -97,7 +99,7 @@ LazyImage.register('lazy-image'); host.addController(this); } - async onMount() { + async hostConnected() { this.loading = true; this.host.requestUpdate(); @@ -115,7 +117,7 @@ LazyImage.register('lazy-image'); } } - onUnmount() { + hostDisconnected() { // Could abort an in-flight request here if using AbortController } } diff --git a/docs/app/docs/directives/page.ts b/docs/app/docs/directives/page.ts index 0936b85a..1162b6a4 100644 --- a/docs/app/docs/directives/page.ts +++ b/docs/app/docs/directives/page.ts @@ -5,99 +5,101 @@ export const metadata = { title: 'Directives | webjs' }; export default function Directives() { return html`webjs follows a "less is more" philosophy. Only three directives are built in, and each solves a problem that has no native alternative. Everything else uses native JavaScript and HTML patterns.
+webjs ships the full lit-html directive set. AI agents writing lit-shaped directive code land on familiar names; the implementations live in packages/core/src/directives.js and the renderers (render-server.js, render-client.js).
import { repeat } from '@webjskit/core'; // keyed lists
-import { unsafeHTML, live } from '@webjskit/core/directives'; // raw HTML, input sync
-
- Essential. Keyed list reconciliation. Without it, re-rendering an array destroys and recreates all DOM nodes, losing focus, scroll position, and component state.
import { html, repeat } from '@webjskit/core';
+import {
+ unsafeHTML, live,
+ keyed, guard, templateContent, ref, createRef,
+ cache, until, asyncAppend, asyncReplace,
+} from '@webjskit/core/directives';
-html\`<ul>
+ Keyed list reconciliation. Without it, re-rendering an array destroys and recreates all DOM nodes, losing focus, scroll position, and component state.
+html\`<ul>
\${repeat(
items,
- (item) => item.id, // stable unique key
+ (item) => item.id,
(item) => html\`<li>\${item.name}</li>\`
)}
</ul>\`;
- When to use: Any list where items can be added, removed, or reordered and you need to preserve DOM identity (e.g., animated lists, forms with inputs, draggable items).
-When NOT to use: Static lists or lists that always re-render fully. Use plain \${items.map(...)} instead.
Use for any list where items can be added, removed, or reordered and you need to preserve DOM identity (animated lists, forms with inputs, draggable items). For static lists, plain \${items.map(...)} works.
Essential. Renders a raw HTML string without escaping. The only way to inject pre-built HTML (CMS content, markdown output) into a template.
-import { unsafeHTML } from '@webjskit/core/directives';
-
-// Trusted markdown output
-html\`<article>\${unsafeHTML(markdownToHtml(post.body))}</article>\`;
- Security warning: NEVER use with user-supplied input. This is an XSS vector. Only use with content you control or have sanitized.
+Renders a raw HTML string without escaping. The only way to inject pre-built HTML (CMS content, markdown output) into a template.
+html\`<article>\${unsafeHTML(markdownToHtml(post.body))}</article>\`;
+ Security: NEVER use with user-supplied input. This is an XSS vector.
Essential. Dirty-checks against the live DOM value instead of the last rendered value. Solves the input desync problem where the user types between renders.
-import { live } from '@webjskit/core/directives';
-
-html\`<input .value=\${live(this.state.query)}
+ Dirty-checks against the live DOM value instead of the last rendered value. Solves the input desync problem where the user types between renders.
+ html\`<input .value=\${live(this.state.query)}
@input=\${(e) => this.setState({ query: e.target.value })}>\`;
- When to use: .value, .checked, or .selectedIndex bindings on <input>, <textarea>, <select> where the user can modify the DOM value between renders.
+
+ keyed(key, template)
+ Wrap a template with a key. When the key changes between renders, the renderer discards the prior DOM and creates fresh. Useful for forcing a remount when the logical identity of the rendered content changes.
+ html\`\${keyed(this.userId, html\`<edit-form .user=\${this.user}></edit-form>\`)}\`;
+
+ guard(deps, fn)
+ Memoize a sub-template by its dependencies. The client renderer skips re-evaluating fn() when the deps array is shallow-equal to the prior call. On the server (one-shot render) fn() is always invoked.
+ html\`<header>
+ \${guard([this.title], () => html\`<h1>\${this.title}</h1>\`)}
+</header>\`;
+
+ templateContent(tpl)
+ Render the content of a native <template> element. The content is cloned on the client; on the server, its innerHTML is emitted verbatim. The HTML inside the template is trusted (not escaped).
+ const tpl = document.querySelector('#my-tpl');
+html\`<div>\${templateContent(tpl)}</div>\`;
+
+ ref(refOrCallback) + createRef()
+ Bind a Ref object or callback to the element at this position. The Ref's value is populated after the first client-side render. SSR is a no-op (no DOM yet).
+ class MyForm extends WebComponent {
+ _input = createRef();
+ render() { return html\`<input \${ref(this._input)}>\`; }
+ firstUpdated() { this._input.value?.focus(); }
+}
+ Pass a callback instead of a Ref object to receive the element directly: \${ref((el) => this._captureEl(el))}.
+
+ cache(value)
+ Retain detached DOM when toggling between sub-templates. When the inner value's template strings match a previously-rendered template at this position, the renderer re-attaches the stashed nodes and reconciles values instead of creating fresh DOM. Preserves input state, scroll position, and focus across "tab"-style toggles.
+ render() {
+ return html\`
+ <nav>
+ <button @click=\${() => this.tab = 'a'}>A</button>
+ <button @click=\${() => this.tab = 'b'}>B</button>
+ </nav>
+ \${cache(this.tab === 'a' ? html\`<panel-a></panel-a>\` : html\`<panel-b></panel-b>\`)}
+ \`;
+}
+ On the server, cache is a pass-through (one-shot render, no DOM to cache).
+
+ until(...args)
+ Render the highest-priority resolved candidate. Priority is left-to-right: args[0] is highest. The highest-priority synchronous candidate renders immediately; higher-priority Promises that later resolve replace the rendered value. Lower-priority Promises are ignored once a higher-priority candidate is in place.
+ html\`<div>\${until(this.dataPromise, html\`<p>Loading…</p>\`)}</div>\`;
+ When the marker is torn down (a re-render replaces the directive), in-flight Promise tracking is aborted so late resolves cannot overwrite newer DOM. On the server, until awaits Promise.race when all candidates are Promises, or renders the highest-priority synchronous candidate.
+ For component-scoped async data with full pending/error states, prefer the Task controller from @webjskit/core/task.
+
+ asyncAppend(iterable, mapper?) / asyncReplace(iterable, mapper?)
+ Stream values from an AsyncIterable. Each yielded value is mapped (optional) and rendered as a node group. asyncAppend accumulates the rendered groups before the marker; asyncReplace swaps out the previous output each yield. Iteration aborts when the directive is replaced (so leaked iterators don't hold references to detached DOM).
+ async function* logTail() {
+ for await (const line of socket) yield line;
+}
+
+html\`<ul>\${asyncAppend(logTail(), (line, i) => html\`<li>\${i}: \${line}</li>\`)}</ul>\`;
+ On the server, both directives render empty (no iteration on a one-shot render). For page-level streaming, prefer Suspense({ fallback, children }).
Native patterns (no directive needed)
- For everything else, use native JavaScript. AI agents generate these patterns automatically:
+ For conditional classes, inline styles, optional attributes, conditional rendering, async data with full lifecycle, the lit-html directive set has classMap/styleMap/ifDefined/when/choose/until/etc. webjs ships these as runtime exports for parity, but the framework's preference for these specific cases is native JavaScript inside render(). AI agents emit either form correctly; the native form has no runtime overhead and shows up directly in the template.
Conditional CSS classes
- // Instead of classMap({active: x, error: y}):
-html\`<div class=\${[x && 'active', y && 'error'].filter(Boolean).join(' ')}>\`;
+ html\`<div class=\${[x && 'active', y && 'error'].filter(Boolean).join(' ')}>\`;
Dynamic inline styles
- // Instead of styleMap({color: c, fontSize: s}):
-html\`<div style=\${\`color:\${c};font-size:\${s}\`}>\`;
+ html\`<div style=\${\`color:\${c};font-size:\${s}\`}>\`;
Optional attributes
- // Instead of ifDefined(val):
-html\`<img src=\${src ?? null}>\`; // null removes the attribute
+ html\`<img src=\${src ?? null}>\`; // null removes the attribute
Conditional rendering
- // Instead of when(cond, a, b):
-html\`\${loggedIn ? html\`<p>Welcome</p>\` : html\`<a href="/login">Sign in</a>\`}\`;
-
- Element references
- // Instead of ref(callback):
-firstUpdated() {
- this.canvas = this.query('canvas');
- this.ctx = this.canvas.getContext('2d');
-}
-
- Memoization
- // Instead of guard(deps, fn):
-willUpdate(changed) {
- if (changed.has('items')) {
- this.__expensiveResult = computeExpensive(this.state.items);
- }
-}
-
- Async data in components
- // Instead of until(promise, fallback):
-// Use the Task controller (handles loading, error, abort):
-#task = new Task(this, {
- task: async ([id], { signal }) => fetch(\`/api/\${id}\`, { signal }).then(r => r.json()),
- args: () => [this.userId],
-});
-render() { return this.#task.render({ pending: () => html\`Loading...\`, complete: (d) => html\`\${d.name}\` }); }
-
- Preserve DOM (tabs/views)
- // Instead of cache(template):
-// Use CSS to hide inactive views, with the DOM staying in memory:
-html\`
- <div style=\${\`display:\${tab === 'a' ? 'block' : 'none'}\`}>Tab A content</div>
- <div style=\${\`display:\${tab === 'b' ? 'block' : 'none'}\`}>Tab B content</div>
-\`;
-
- Why "less is more"
- webjs is an AI-first framework. AI agents don't need syntax sugar, since they generate verbose code as easily as terse code. Fewer directives means:
- html\`\${loggedIn ? html\`<p>Welcome</p>\` : html\`<a href="/login">Sign in</a>\`}\`;
`;
}
diff --git a/docs/app/docs/lifecycle/page.ts b/docs/app/docs/lifecycle/page.ts
index f68e0d80..dbeaec5a 100644
--- a/docs/app/docs/lifecycle/page.ts
+++ b/docs/app/docs/lifecycle/page.ts
@@ -5,65 +5,86 @@ export const metadata = { title: 'Lifecycle Hooks | webjs' };
export default function Lifecycle() {
return html`
webjs follows a "less is more" philosophy for lifecycle hooks. Only hooks with no native workaround are included. AI agents don't need abstractions for things that a few lines of code can handle.
+webjs ships the full lit-aligned component lifecycle. AI coding agents have substantial training data on lit, so adopting lit's hook names and semantics lets agents write idiomatic webjs code without framework-specific translation.
When setState() or a property change triggers a re-render:
Every render goes through this pipeline. Each hook receives a changedProperties Map where keys are property names (or 'state' for setState patches) and values are the previous value before the change.
beforeRender()render() + DOM commit (with error boundary)afterRender()firstUpdated() runs once, on the first render onlyshouldUpdate(changedProperties) returns false to skip this update entirely.willUpdate(changedProperties) is the pre-render hook. Safe to set reactive properties; assignments fold into this cycle.hostUpdate()update(changedProperties) is the render-and-commit step. The default implementation calls render() and commits to the render root.hostUpdated()firstUpdated(changedProperties) runs once, on the first render only.updated(changedProperties) runs after every render commit.updateComplete Promise resolves.The core of every component. Returns a TemplateResult via the html tag. Called on every state change.
The template the component should produce for the current state. Returns a TemplateResult via the html tag.
render() {
- // Derived state goes here, before the template:
- const filtered = this.state.items.filter(i => i.active);
- const count = filtered.length;
-
return html\`
- <p>\${count} active items</p>
- <ul>\${filtered.map(i => html\`<li>\${i.name}</li>\`)}</ul>
+ <p>\${this.filtered.length} active items</p>
+ <ul>\${this.filtered.map(i => html\`<li>\${i.name}</li>\`)}</ul>
\`;
}
- No willUpdate needed. Compute derived state at the top of render().
Shallow-merges the patch into this.state and schedules a microtask-batched re-render. Multiple setState calls within the same microtask are batched into one render.
this.setState({ count: this.state.count + 1 });
-this.setState({ name: 'updated' });
-// Only one render happens
+ Decide whether to render at all. Default returns true. Use to skip expensive renders when only irrelevant properties changed.
shouldUpdate(cp) {
+ return cp.has('items') || cp.has('mode');
+}
- Called once after the first render. The shadow DOM is populated, so you can query elements. Use for one-time setup: focus, measurements, third-party library init.
+Compute derived values from inputs before render() reads them. Property assignments inside willUpdate fold into the current cycle without triggering another update.
willUpdate(cp) {
+ if (cp.has('items')) {
+ this.totalCount = this.items.length;
+ }
+}
+
+ The render-and-commit step. The default implementation calls render() and commits to the render root. Override only when you need to wrap or short-circuit the commit. Most users override render() instead.
Post-render DOM work. Runs after every commit (both the first render and all subsequent ones). Inspect changedProperties to branch on what changed this cycle. This is the right place for ad-hoc DOM work that previously needed requestAnimationFrame shims.
updated(cp) {
+ if (cp.has('open') && this.open) {
+ this.querySelector('input')?.focus();
+ }
+}
+
+ Runs once, after the first render. Use for one-time DOM-dependent setup: focus, measurements, third-party library init on a DOM node. The changedProperties Map on the first render contains every reactive property that has a value, with undefined as the old value.
firstUpdated() {
- this.shadowRoot.querySelector('input')?.focus();
+ this.shadowRoot?.querySelector('input')?.focus();
this._chart = new Chart(this.shadowRoot.querySelector('canvas'));
}
- connectedCallback fires before the first render, so shadow children don't exist there yet. That's why firstUpdated exists.
connectedCallback fires before the first render, so shadow children don't exist there yet. firstUpdated is the post-render equivalent.
A Promise that resolves after the next render commit. await el.updateComplete in tests or in code that needs to read the post-render DOM after triggering an update. Override getUpdateComplete() to chain additional async work.
el.count = 5; +await el.updateComplete; +// DOM now reflects count = 5+ +
Shallow-merges the patch into this.state and schedules a microtask-batched re-render. Multiple setState calls within the same microtask coalesce into one render. The changedProperties Map includes a 'state' entry whose old value is the previous state bag, so lifecycle hooks can detect setState invocations.
this.setState({ count: this.state.count + 1 });
+this.setState({ name: 'updated' });
+// One render. changedProperties.has('state') is true; .get('state') is the prior bag.
+
+ Manually schedule a re-render. Optionally record a property change so hooks see it in changedProperties. Used by controllers and code that mutates outside the reactive property system.
this.requestUpdate('items', oldItems);
Called when render() throws. Return a fallback template to show instead of crashing the page.
Runs when update()/render() throws. Return a fallback template to show instead of crashing the page.
renderError(error) {
return html\`<p style="color:red">Error: \${error.message}</p>\`;
}
Without this, one broken component would crash the entire page. The default implementation renders nothing and logs to console.
-| Hook | Native workaround |
|---|---|
shouldUpdate | Return early from render() with an if-statement |
willUpdate | Compute at the top of render() |
updated | Use queueMicrotask() after setState() |
changedProperties | Track manually with this._prev = {...this.state} |
query(sel) | this.shadowRoot.querySelector(sel) |
These abstractions add API surface without solving problems that native code can't. Fewer hooks = fewer concepts for AI agents to choose between.
-These are provided by HTMLElement itself and work as normal in webjs components:
disconnectedCallback()firstUpdated()attributeChangedCallback()beforeRender / afterRenderhostUpdate / hostUpdatedLoading…
`)}v=${this.v}
`; } + } + customElements.define('lc-uc-1', LcUcEl); + const el = document.createElement('lc-uc-1'); + document.body.appendChild(el); + await el.updateComplete; + assert.equal(el.querySelector('p').textContent, 'v=0'); + + el.v = 42; + await el.updateComplete; + assert.equal(el.querySelector('p').textContent, 'v=42'); + el.remove(); + }); + + test('shouldUpdate=false in real browser skips the DOM commit', async () => { + let renders = 0; + class LcSuEl extends WebComponent { + static properties = { x: { type: Number } }; + constructor() { super(); this.x = 0; } + shouldUpdate() { return this.x !== 99; } + render() { renders++; return html`x=${this.x}
`; } + } + customElements.define('lc-su-1', LcSuEl); + const el = document.createElement('lc-su-1'); + document.body.appendChild(el); + await el.updateComplete; + const baselineRenders = renders; + assert.equal(el.querySelector('p').textContent, 'x=0'); + + el.x = 99; + await el.updateComplete; + assert.equal(renders, baselineRenders); + // DOM still shows the prior render. + assert.equal(el.querySelector('p').textContent, 'x=0'); + el.remove(); + }); + + test('willUpdate mutations fold into the same render (no second microtask)', async () => { + let renders = 0; + class LcWuEl extends WebComponent { + static properties = { + a: { type: Number }, + b: { type: Number, state: true }, + }; + constructor() { super(); this.a = 0; this.b = -1; } + willUpdate(cp) { + if (cp.has('a')) this.b = this.a * 2; + } + render() { renders++; return html`a=${this.a},b=${this.b}
`; } + } + customElements.define('lc-wu-1', LcWuEl); + const el = document.createElement('lc-wu-1'); + document.body.appendChild(el); + await el.updateComplete; + const baseline = renders; + + el.a = 7; + await el.updateComplete; + assert.equal(el.b, 14); + assert.equal(el.querySelector('p').textContent, 'a=7,b=14'); + // Exactly one new render. + assert.equal(renders - baseline, 1); + el.remove(); + }); + + test('updated() runs after the DOM is live; can read post-render layout', async () => { + let measured = null; + class LcUdEl extends WebComponent { + static properties = { tall: { type: Boolean } }; + constructor() { super(); this.tall = false; } + updated(cp) { + if (cp.has('tall')) { + measured = this.querySelector('div').offsetHeight; + } + } + render() { + return html`${this.v}
`; } + } + customElements.define('lc-fu-1', LcFuEl); + const el = document.createElement('lc-fu-1'); + document.body.appendChild(el); + await el.updateComplete; + el.v = 1; await el.updateComplete; + el.v = 2; await el.updateComplete; + el.v = 3; await el.updateComplete; + assert.equal(firsts, 1); + el.remove(); + }); + + test('setState routes through changedProperties (key "state", oldValue is prior bag)', async () => { + let captured = null; + class LcSsEl extends WebComponent { + constructor() { super(); this.state = { count: 1 }; } + updated(cp) { + if (cp.has('state')) captured = cp.get('state'); + } + render() { return html`${this.state.count}
`; } + } + customElements.define('lc-ss-1', LcSsEl); + const el = document.createElement('lc-ss-1'); + document.body.appendChild(el); + await el.updateComplete; + captured = null; + + el.setState({ count: 2 }); + await el.updateComplete; + assert.deepEqual(captured, { count: 1 }); + assert.equal(el.querySelector('p').textContent, '2'); + el.remove(); + }); + + test('getUpdateComplete override chains additional work', async () => { + let extraDone = false; + class LcGcEl extends WebComponent { + static properties = { v: { type: Number } }; + constructor() { super(); this.v = 0; } + async getUpdateComplete() { + const r = await super.getUpdateComplete(); + await new Promise(res => setTimeout(res, 10)); + extraDone = true; + return r; + } + render() { return html`${this.v}
`; } + } + customElements.define('lc-gc-1', LcGcEl); + const el = document.createElement('lc-gc-1'); + document.body.appendChild(el); + await el.updateComplete; + assert.ok(extraDone, 'Override ran extra work before updateComplete settled'); + el.remove(); + }); + + test('throwing willUpdate does NOT deadlock the component', async () => { + let willThrows = true; + class LcThrowEl extends WebComponent { + static properties = { v: { type: Number } }; + constructor() { super(); this.v = 0; } + willUpdate() { + if (willThrows) { + willThrows = false; + throw new Error('willUpdate boom'); + } + } + render() { return html`v=${this.v}
`; } + } + customElements.define('lc-throw-1', LcThrowEl); + const el = document.createElement('lc-throw-1'); + document.body.appendChild(el); + + // First update: willUpdate throws. The component should NOT deadlock. + let firstCompleted = false; + try { + await Promise.race([ + el.updateComplete.then(() => { firstCompleted = true; }), + new Promise((_, rej) => setTimeout(() => rej(new Error('deadlock')), 200)), + ]); + } catch (e) { + if (e.message === 'deadlock') throw e; + } + assert.ok(firstCompleted, 'updateComplete resolved even after willUpdate throw'); + + // Second update: willUpdate no longer throws. Component must render. + el.v = 42; + await el.updateComplete; + assert.equal(el.querySelector('p').textContent, 'v=42'); + el.remove(); + }); + + test('shouldUpdate=false preserves changedProperties for the next cycle', async () => { + let allow = false; + const seen = []; + class LcGateEl extends WebComponent { + static properties = { + a: { type: Number }, + b: { type: Number }, + }; + constructor() { super(); this.a = 0; this.b = 0; } + shouldUpdate() { return allow; } + updated(cp) { seen.push([...cp.keys()].sort()); } + render() { return html`a=${this.a},b=${this.b}
`; } + } + customElements.define('lc-gate-1', LcGateEl); + const el = document.createElement('lc-gate-1'); + document.body.appendChild(el); + // Initial render is gated. + await el.updateComplete; + assert.deepEqual(seen, []); // shouldUpdate=false from the start + + // Make changes while gated. + el.a = 1; + await el.updateComplete; + el.b = 2; + await el.updateComplete; + assert.deepEqual(seen, []); // still gated + + // Open the gate. The next cycle should see BOTH 'a' and 'b' (plus any + // initial entries) in changedProperties, because they were preserved + // across the gated cycles. + allow = true; + el.a = 3; + await el.updateComplete; + const lastKeys = seen[seen.length - 1]; + assert.ok(lastKeys.includes('a'), 'a was preserved'); + assert.ok(lastKeys.includes('b'), 'b was preserved'); + el.remove(); + }); + + test('ReactiveController with lit hostConnected/hostUpdate/hostUpdated/hostDisconnected', async () => { + const order = []; + const controller = { + hostConnected() { order.push('hostConnected'); }, + hostUpdate() { order.push('hostUpdate'); }, + hostUpdated() { order.push('hostUpdated'); }, + hostDisconnected() { order.push('hostDisconnected'); }, + }; + class LcCtEl extends WebComponent { + constructor() { super(); this.addController(controller); } + render() { return html`ok
`; } + } + customElements.define('lc-ct-1', LcCtEl); + const el = document.createElement('lc-ct-1'); + document.body.appendChild(el); + await el.updateComplete; + el.remove(); + // Allow the disconnectedCallback's host.disconnected propagation. + await new Promise(r => requestAnimationFrame(r)); + assert.ok(order.includes('hostConnected'), 'hostConnected fired'); + assert.ok(order.includes('hostUpdate'), 'hostUpdate fired'); + assert.ok(order.includes('hostUpdated'), 'hostUpdated fired'); + assert.ok(order.includes('hostDisconnected'), 'hostDisconnected fired'); + }); +}); diff --git a/test/browser/controllers-port_test.js b/test/browser/controllers-port_test.js new file mode 100644 index 00000000..359cf45d --- /dev/null +++ b/test/browser/controllers-port_test.js @@ -0,0 +1,408 @@ +/** + * Port of lit's reactive-element-controllers test suite. + * + * Source: packages/reactive-element/src/test/reactive-element-controllers_test.ts + * in https://github.com/lit/lit. + * + * Adapted for webjs's WebComponent. Controllers are duck-typed objects with + * optional hostConnected / hostDisconnected / hostUpdate / hostUpdated + * methods, attached via host.addController(controller) / removeController. + */ +import { html } from '../../packages/core/src/html.js'; +import { WebComponent } from '../../packages/core/src/component.js'; + +const assert = { + ok: (v, msg) => { if (!v) throw new Error(msg || `Expected truthy, got ${v}`); }, + equal: (a, b, msg) => { if (a !== b) throw new Error(msg || `Expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}`); }, + deepEqual: (a, b, msg) => { + if (JSON.stringify(a) !== JSON.stringify(b)) { + throw new Error(msg || `deepEqual failed: ${JSON.stringify(a)} !== ${JSON.stringify(b)}`); + } + }, +}; + +let _uid = 0; +function uniqTag(prefix) { + _uid++; + return `${prefix}-${Date.now().toString(36)}-${_uid}`; +} + +suite('Reactive controllers (port from lit)', () => { + + class MyController { + constructor(host) { + this.host = host; + this.updateCount = 0; + this.updatedCount = 0; + this.connectedCount = 0; + this.disconnectedCount = 0; + this.callbackOrder = []; + this.host.addController(this); + } + hostConnected() { + this.connectedCount++; + this.callbackOrder.push('hostConnected'); + } + hostDisconnected() { + this.disconnectedCount++; + this.callbackOrder.push('hostDisconnected'); + } + hostUpdate() { + this.updateCount++; + this.callbackOrder.push('hostUpdate'); + } + hostUpdated() { + this.updatedCount++; + this.callbackOrder.push('hostUpdated'); + } + } + + // Build a fresh host class per test so element-registration is unique and + // tests are isolated. Returns { ElClass, makeEl } so tests can either + // construct with `new` (the lit pattern) or via document.createElement. + function makeHostClass() { + class A extends WebComponent { + static properties = { foo: { type: String } }; + constructor() { + super(); + this.foo = 'foo'; + this.updateCount = 0; + this.updatedCount = 0; + this.connectedCount = 0; + this.disconnectedCount = 0; + // Lit's pattern uses `controller = new MyController(this)` as a + // class field. Class fields run after super(), so this is the + // same point in the constructor. + this.controller = new MyController(this); + } + connectedCallback() { + this.connectedCount++; + super.connectedCallback(); + this.controller.callbackOrder.push('connectedCallback'); + } + disconnectedCallback() { + this.disconnectedCount++; + super.disconnectedCallback(); + this.controller.callbackOrder.push('disconnectedCallback'); + } + update(changedProperties) { + this.updateCount++; + super.update(changedProperties); + this.controller.callbackOrder.push('update'); + } + firstUpdated() { + this.controller.callbackOrder.push('firstUpdated'); + } + updated() { + this.updatedCount++; + this.controller.callbackOrder.push('updated'); + } + render() { return html`${this.foo}
`; } + } + const tag = uniqTag('rc-host'); + customElements.define(tag, A); + return { A, tag }; + } + + let container; + + setup(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + teardown(() => { + if (container && container.parentNode) { + container.parentNode.removeChild(container); + } + }); + + // Helper: create + mount an element with the standard host class. Returns + // the element with its `controller` field already wired (constructor ran + // and addController was called). + async function mountStandardEl() { + const { tag } = makeHostClass(); + const el = document.createElement(tag); + container.appendChild(el); + await el.updateComplete; + return el; + } + + test('controllers can implement hostConnected/hostDisconnected', async () => { + const el = await mountStandardEl(); + assert.equal(el.connectedCount, 1); + assert.equal(el.disconnectedCount, 0); + assert.equal(el.controller.connectedCount, 1); + assert.equal(el.controller.disconnectedCount, 0); + container.removeChild(el); + assert.equal(el.connectedCount, 1); + assert.equal(el.disconnectedCount, 1); + assert.equal(el.controller.connectedCount, 1); + assert.equal(el.controller.disconnectedCount, 1); + container.appendChild(el); + assert.equal(el.connectedCount, 2); + assert.equal(el.disconnectedCount, 1); + assert.equal(el.controller.connectedCount, 2); + assert.equal(el.controller.disconnectedCount, 1); + }); + + test('controllers can implement hostUpdate/hostUpdated', async () => { + const el = await mountStandardEl(); + assert.equal(el.updateCount, 1); + assert.equal(el.updatedCount, 1); + assert.equal(el.controller.updateCount, 1); + assert.equal(el.controller.updatedCount, 1); + el.foo = 'new'; + await el.updateComplete; + assert.equal(el.updateCount, 2); + assert.equal(el.updatedCount, 2); + assert.equal(el.controller.updateCount, 2); + assert.equal(el.controller.updatedCount, 2); + }); + + test('controllers can be removed', async () => { + const el = await mountStandardEl(); + assert.equal(el.controller.connectedCount, 1); + assert.equal(el.controller.disconnectedCount, 0); + assert.equal(el.controller.updateCount, 1); + assert.equal(el.controller.updatedCount, 1); + el.removeController(el.controller); + el.foo = 'new'; + await el.updateComplete; + el.remove(); + assert.equal(el.controller.connectedCount, 1); + assert.equal(el.controller.disconnectedCount, 0); + assert.equal(el.controller.updateCount, 1); + assert.equal(el.controller.updatedCount, 1); + }); + + test('controllers callback order', async () => { + const el = await mountStandardEl(); + // webjs defers the first render to a microtask after connectedCallback + // returns, matching lit's reactive-element schedule. The subclass's + // connectedCallback body pushes 'connectedCallback' before any render + // hooks fire; the microtask then runs hostUpdate / update / hostUpdated + // / firstUpdated / updated. + assert.deepEqual(el.controller.callbackOrder, [ + 'hostConnected', + 'connectedCallback', + 'hostUpdate', + 'update', + 'hostUpdated', + 'firstUpdated', + 'updated', + ]); + el.controller.callbackOrder = []; + el.foo = 'new'; + await el.updateComplete; + assert.deepEqual(el.controller.callbackOrder, [ + 'hostUpdate', + 'update', + 'hostUpdated', + 'updated', + ]); + el.controller.callbackOrder = []; + container.removeChild(el); + assert.deepEqual(el.controller.callbackOrder, [ + 'hostDisconnected', + 'disconnectedCallback', + ]); + }); + + test('controllers added after element is first connected call hostConnected', async () => { + const el = await mountStandardEl(); + const controller = new MyController(el); + assert.equal(controller.connectedCount, 1); + assert.equal(controller.disconnectedCount, 0); + container.removeChild(el); + assert.equal(controller.disconnectedCount, 1); + container.appendChild(el); + assert.equal(controller.connectedCount, 2); + assert.equal(controller.disconnectedCount, 1); + }); + + test('controllers added on an upgraded element call hostConnected once', async () => { + // Create the element BEFORE its class is registered, then upgrade. + const { A } = makeHostClass(); + class B extends A {} + const tag = uniqTag('rc-upgraded'); + const el = document.createElement(tag); + container.appendChild(el); + customElements.define(tag, B); + await el.updateComplete; + assert.equal(el.controller.connectedCount, 1); + assert.equal(el.controller.disconnectedCount, 0); + container.removeChild(el); + assert.equal(el.controller.disconnectedCount, 1); + container.appendChild(el); + assert.equal(el.controller.connectedCount, 2); + assert.equal(el.controller.disconnectedCount, 1); + }); + + test('controllers can be removed during lifecycle', async () => { + const el = await mountStandardEl(); + class RemovingController { + constructor(host) { + this.host = host; + this.updatedCount = 0; + this.host.addController(this); + } + hostUpdated() { + this.updatedCount++; + this.host.removeController(this); + } + } + const removingController = new RemovingController(el); + const controller = new MyController(el); + assert.equal(el.controller.updatedCount, 1); + assert.equal(removingController.updatedCount, 0); + assert.equal(controller.updatedCount, 0); + el.requestUpdate(); + await el.updateComplete; + assert.equal(el.controller.updatedCount, 2); + assert.equal(removingController.updatedCount, 1); + assert.equal(controller.updatedCount, 1); + el.requestUpdate(); + await el.updateComplete; + assert.equal(el.controller.updatedCount, 3); + assert.equal(removingController.updatedCount, 1); + assert.equal(controller.updatedCount, 2); + }); + + test('controllers can add other controllers during lifecycle', async () => { + const el = await mountStandardEl(); + class AddingController { + constructor(host) { + this.host = host; + this.updateCount = 0; + this.controllers = undefined; + this.host.addController(this); + } + hostUpdate() { + this.updateCount++; + (this.controllers ??= []).push(new MyController(this.host)); + } + } + const addingController = new AddingController(el); + const controller = new MyController(el); + assert.equal(el.controller.updatedCount, 1); + assert.equal(addingController.updateCount, 0); + assert.equal(controller.updateCount, 0); + el.requestUpdate(); + await el.updateComplete; + assert.equal(el.controller.updateCount, 2); + assert.equal(addingController.updateCount, 1); + assert.equal(addingController.controllers && addingController.controllers.length, 1); + assert.equal(addingController.controllers[0].updateCount, 1); + assert.equal(controller.updateCount, 1); + el.requestUpdate(); + await el.updateComplete; + assert.equal(el.controller.updateCount, 3); + assert.equal(addingController.updateCount, 2); + assert.equal(addingController.controllers.length, 2); + assert.equal(addingController.controllers[0].updateCount, 2); + assert.equal(addingController.controllers[1].updateCount, 1); + assert.equal(controller.updateCount, 2); + }); + + // Additional coverage beyond the lit suite: hooks fire on multiple + // controllers in registration order. + test('multiple controllers: all hooks fire in registration order', async () => { + const order = []; + function makeTracking(name) { + return { + hostConnected() { order.push(`${name}:hostConnected`); }, + hostUpdate() { order.push(`${name}:hostUpdate`); }, + hostUpdated() { order.push(`${name}:hostUpdated`); }, + hostDisconnected() { order.push(`${name}:hostDisconnected`); }, + }; + } + class MultiEl extends WebComponent { + render() { return html`ok
`; } + } + const tag = uniqTag('rc-multi'); + customElements.define(tag, MultiEl); + const el = document.createElement(tag); + const a = makeTracking('a'); + const b = makeTracking('b'); + const c = makeTracking('c'); + el.addController(a); + el.addController(b); + el.addController(c); + container.appendChild(el); + await el.updateComplete; + // hostConnected fires for a, b, c in order, then hostUpdate triplet, + // then hostUpdated triplet. + assert.deepEqual(order.slice(0, 9), [ + 'a:hostConnected', 'b:hostConnected', 'c:hostConnected', + 'a:hostUpdate', 'b:hostUpdate', 'c:hostUpdate', + 'a:hostUpdated', 'b:hostUpdated', 'c:hostUpdated', + ]); + order.length = 0; + el.remove(); + assert.deepEqual(order, [ + 'a:hostDisconnected', 'b:hostDisconnected', 'c:hostDisconnected', + ]); + }); + + // Controller's requestUpdate(name, oldValue) propagates to host's + // changedProperties (controllers commonly call host.requestUpdate). + test('controller requestUpdate(name, oldValue) propagates to changedProperties', async () => { + const seen = []; + class PropEl extends WebComponent { + static properties = { foo: { type: String } }; + constructor() { super(); this.foo = 'x'; } + updated(cp) { + // Snapshot a plain object so the recorded values can't change later. + const entries = {}; + for (const [k, v] of cp.entries()) entries[k] = v; + seen.push(entries); + } + render() { return html`${this.foo}
`; } + } + const tag = uniqTag('rc-prop'); + customElements.define(tag, PropEl); + const el = document.createElement(tag); + container.appendChild(el); + await el.updateComplete; + // Initial render: entries for foo (initial undefined -> 'x') + // Now a controller mutates a property and requests update via host. + const ctl = { + host: el, + bump() { + const old = this.host.foo; + this.host.foo = 'y'; + // The setter calls requestUpdate(name, oldValue) automatically, + // but a controller might also call host.requestUpdate(name, oldValue) + // for a virtual property. Exercise that path too. + this.host.requestUpdate('virtual', 'before'); + }, + }; + el.addController(ctl); + ctl.bump(); + await el.updateComplete; + const last = seen[seen.length - 1]; + assert.equal(last.foo, 'x', 'old value of foo recorded in changedProperties'); + assert.equal(last.virtual, 'before', 'virtual prop entry recorded'); + }); + + // host.addController during connectedCallback (the controller is added + // while the host is already connected) should call hostConnected once. + test('addController during connectedCallback fires hostConnected once', async () => { + let connectedFires = 0; + const ctl = { hostConnected() { connectedFires++; } }; + class LateEl extends WebComponent { + connectedCallback() { + super.connectedCallback(); + this.addController(ctl); + } + render() { return html`ok
`; } + } + const tag = uniqTag('rc-late'); + customElements.define(tag, LateEl); + const el = document.createElement(tag); + container.appendChild(el); + await el.updateComplete; + assert.equal(connectedFires, 1); + }); +}); diff --git a/test/browser/directives-async-stream_test.js b/test/browser/directives-async-stream_test.js new file mode 100644 index 00000000..680a3632 --- /dev/null +++ b/test/browser/directives-async-stream_test.js @@ -0,0 +1,354 @@ +/** + * Ported from lit-html's `asyncAppend` and `asyncReplace` directive test + * suites (packages/lit-html/src/test/directives/async-append_test.ts + + * async-replace_test.ts) to exercise webjs's implementations in + * render-client.js (applyAsyncAppend / applyAsyncReplace / + * consumeAsyncStream / teardownAsyncStream). + * + * Goal: shake out bugs in DOM appending, replacement, mapper handling, + * teardown on re-render, and pending-iterable swap behaviour. + * + * Skipped tests (and why): + * - asyncReplace (AttributePart / PropertyPart / BooleanAttributePart + * / EventPart): webjs only handles asyncReplace at child positions + * (see render-client.js isAsyncReplace dispatch around line 846). + * Attribute / property / boolean / event positions are not in the + * documented surface for webjs's async stream directives. + * - disconnection sub-suite: webjs's `render(v, container)` returns + * undefined, no `part.setConnected(...)` API. Pause / resume of + * in-flight iteration is not part of webjs's directive contract. + * - memory leak tests: depend on `window.gc()` (only available with + * --js-flags=--expose-gc) and `performance.memory` (Chromium only, + * and even there only with a flag in newer versions). Out of scope + * for a portable browser test. + */ + +import { html } from '../../packages/core/src/html.js'; +import { render } from '../../packages/core/src/render-client.js'; +import { asyncAppend, asyncReplace } from '../../packages/core/src/directives.js'; + +const assert = { + ok: (v, msg) => { if (!v) throw new Error(msg || `Expected truthy, got ${v}`); }, + equal: (a, b, msg) => { if (a !== b) throw new Error(msg || `Expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}`); }, +}; + +const nextFrame = () => new Promise((r) => requestAnimationFrame(() => r())); + +/** + * Strip webjs marker comments so HTML assertions can match lit's + * `stripExpressionMarkers` shape. + */ +function stripExpressionMarkers(s) { + return s.replace(//g, ''); +} + +/** + * Minimal port of lit's TestAsyncIterable. A push-driven async iterable + * intended for single-consumer use. `push(v)` resolves the next pending + * `_nextValue` promise and waits one rAF so that downstream renderers + * have had a chance to commit. + */ +class TestAsyncIterable { + constructor() { + this._nextValue = new Promise((resolve) => { this._resolveNextValue = resolve; }); + } + async *[Symbol.asyncIterator]() { + while (true) { + yield await this._nextValue; + } + } + async push(value) { + const currentValue = this._nextValue; + const currentResolveValue = this._resolveNextValue; + this._nextValue = new Promise((resolve) => { this._resolveNextValue = resolve; }); + currentResolveValue(value); + await currentValue; + await nextFrame(); + } +} + +/* ================================================================ + * asyncAppend + * ================================================================ */ + +suite('asyncAppend (lit parity port)', () => { + let container; + let iterable; + + setup(() => { + container = document.createElement('div'); + document.body.appendChild(container); + iterable = new TestAsyncIterable(); + }); + + teardown(() => { + container.remove(); + }); + + test('appends content as the async iterable yields new values', async () => { + render(html`${asyncAppend(value)}
`; + + render(component(iterable), container); + render(component(iterable2), container); + + await iterable2.push('fast'); + // This write should not render: the first iterable was replaced. + await iterable.push('slow'); + + assert.equal(stripExpressionMarkers(container.innerHTML), 'fast
'); + }); + + test('the same iterable can be rendered into two asyncAppend instances', async () => { + const component = (iter) => + html`${asyncAppend(iter)}
${asyncAppend(iter)}
`; + render(component(iterable), container); + assert.equal(stripExpressionMarkers(container.innerHTML), ''); + + await iterable.push('1'); + // Each asyncAppend has its own iterator instance from + // [Symbol.asyncIterator](), but they share the underlying + // _nextValue promise. push() resolves once, so both consumers + // observe the same emitted value. + assert.equal(stripExpressionMarkers(container.innerHTML), '1
1
'); + + await iterable.push('2'); + assert.equal(stripExpressionMarkers(container.innerHTML), '12
12
'); + }); +}); + +/* ================================================================ + * asyncReplace + * ================================================================ */ + +suite('asyncReplace (lit parity port)', () => { + let container; + let iterable; + + setup(() => { + container = document.createElement('div'); + document.body.appendChild(container); + iterable = new TestAsyncIterable(); + }); + + teardown(() => { + container.remove(); + }); + + test('replaces content as the async iterable yields new values (ChildPart)', async () => { + render(html`${asyncReplace(value)}
`; + const delay = (ms) => new Promise((res) => setTimeout(res, ms)); + + const slowDelay = delay(20); + const fastDelay = delay(10); + + render(component(generator(slowDelay, 'slow')), container); + render(component(generator(fastDelay, 'fast')), container); + + await slowDelay; + await delay(10); + + assert.equal(stripExpressionMarkers(container.innerHTML), 'fast
'); + }); + + test('the same iterable can be rendered into two asyncReplace instances', async () => { + const component = (iter) => + html`${asyncReplace(iter)}
${asyncReplace(iter)}
`; + render(component(iterable), container); + assert.equal(stripExpressionMarkers(container.innerHTML), ''); + + await iterable.push('1'); + assert.equal(stripExpressionMarkers(container.innerHTML), '1
1
'); + + await iterable.push('2'); + assert.equal(stripExpressionMarkers(container.innerHTML), '2
2
'); + }); +}); diff --git a/test/browser/directives-cache_test.js b/test/browser/directives-cache_test.js new file mode 100644 index 00000000..dec40531 --- /dev/null +++ b/test/browser/directives-cache_test.js @@ -0,0 +1,311 @@ +/** + * Ported from lit-html's cache directive test suite + * (packages/lit-html/src/test/directives/cache_test.ts) to exercise + * webjs's `cache` directive (applyCache in render-client.js). + * + * Goal: surface bugs in DOM retention, re-attach, value reconciliation, + * and detach/reattach lifecycle when toggling between cached templates. + * + * Skipped tests: + * - "caches compiled templates" (lit-internal _$LH compile cache, + * webjs uses TemplateStringsArray identity instead). + * - "cache can switch between TemplateResult and non-TemplateResult" + * uses lit's `nothing` sentinel which webjs does not ship. + * - "async directives disconnect/reconnect when moved in/out of cache" + * requires AsyncDirective + directive() factory which webjs does not + * ship (the analogous lifecycle in webjs is part-state mutation). + */ +import { html } from '../../packages/core/src/html.js'; +import { render } from '../../packages/core/src/render-client.js'; +import { cache } from '../../packages/core/src/directives.js'; + +const assert = { + ok: (v, msg) => { if (!v) throw new Error(msg || `Expected truthy, got ${v}`); }, + equal: (a, b, msg) => { if (a !== b) throw new Error(msg || `Expected ${JSON.stringify(b)}, got ${JSON.stringify(a)}`); }, + notStrictEqual: (a, b, msg) => { if (a === b) throw new Error(msg || 'Expected different references'); }, + strictEqual: (a, b, msg) => { if (a !== b) throw new Error(msg || 'Expected strict equal'); }, +}; + +/** + * Strip webjs marker comments (the framework injects `` + * style comments around dynamic parts; tests assert plain HTML). + */ +function stripExpressionComments(s) { + return s.replace(//g, ''); +} + +suite('cache directive (lit parity port)', () => { + let container; + + setup(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + teardown(() => { + container.remove(); + }); + + test('caches templates', () => { + // Stable factories so `strings` identity is preserved across renders. + // The webjs cache map keys on `strings`; if the template literal is + // re-evaluated per call against fresh strings, the cache miss would + // hide the retention behavior the test wants to verify. + const tplDiv = (v) => html``; + const tplSpan = (v) => html``; + const renderCached = (condition, v) => + render( + html`${cache(condition ? tplDiv(v) : tplSpan(v))}`, + container + ); + + renderCached(true, 'A'); + assert.equal( + stripExpressionComments(container.innerHTML), + '' + ); + const element1 = container.firstElementChild; + + renderCached(false, 'B'); + assert.equal( + stripExpressionComments(container.innerHTML), + '' + ); + const element2 = container.firstElementChild; + + assert.notStrictEqual(element1, element2); + + renderCached(true, 'C'); + assert.equal( + stripExpressionComments(container.innerHTML), + '' + ); + assert.strictEqual(container.firstElementChild, element1, + 'Returning to template A should re-attach the original element'); + + renderCached(false, 'D'); + assert.equal( + stripExpressionComments(container.innerHTML), + '' + ); + assert.strictEqual(container.firstElementChild, element2, + 'Returning to template B should re-attach the original element'); + }); + + // SKIPPED: webjs does not ship lit's _$LH CompiledTemplate format. + // The "caches templates" test above already exercises the equivalent + // path via TemplateStringsArray identity. + + test('renders non-TemplateResults', () => { + render(html`${cache('abc')}`, container); + assert.equal(stripExpressionComments(container.innerHTML), 'abc'); + }); + + test('caches templates when switching against non-TemplateResults', () => { + const tplDiv = (v) => html``; + const renderCached = (condition, v) => + render( + html`${cache(condition ? tplDiv(v) : v)}`, + container + ); + + renderCached(true, 'A'); + assert.equal( + stripExpressionComments(container.innerHTML), + '' + ); + const element1 = container.firstElementChild; + + renderCached(false, 'B'); + assert.equal(stripExpressionComments(container.innerHTML), 'B'); + + renderCached(true, 'C'); + assert.equal( + stripExpressionComments(container.innerHTML), + '' + ); + assert.strictEqual(container.firstElementChild, element1, + 'Re-attaching template A after a non-template excursion should ' + + 'restore the original element'); + + renderCached(false, 'D'); + assert.equal(stripExpressionComments(container.innerHTML), 'D'); + }); + + test('caches templates when switching against TemplateResult and undefined values', () => { + const tplA = html`A`; + const tplB = html`B`; + const renderCached = (v) => + render(html`other
`; + const renderCached = (which) => + render(html`v${calls}
`; })}v${calls}
`; })}${text}
hello
`)}ok
`; } + } + const t = tag('lp-uc-noname'); + customElements.define(t, E); + const el = document.createElement(t); + document.body.appendChild(el); + await el.updateComplete; + assert.equal(updates, 1); + el.requestUpdate(); + await el.updateComplete; + // webjs's requestUpdate() without a name still schedules an update + // (it triggers _scheduleUpdate unconditionally), matching the lit + // semantics where requestUpdate() with no args forces a render. + assert.equal(updates, 2, 'requestUpdate() with no args still re-renders'); + el.remove(); + }); + + test('requestUpdate(name, oldValue) populates changedProperties with the prior value', async () => { + let captured; + class E extends WebComponent { + static properties = { foo: { type: String } }; + constructor() { super(); this.foo = 'a'; } + updated(cp) { captured = new Map(cp); } + render() { return html`${this.foo}
`; } + } + const t = tag('lp-uc-reqold'); + customElements.define(t, E); + const el = document.createElement(t); + document.body.appendChild(el); + await el.updateComplete; + + // Manually call requestUpdate with an explicit oldValue. The map entry + // should preserve the passed oldValue, not the current property value. + el.requestUpdate('foo', 'sentinel-old'); + await el.updateComplete; + assert.ok(captured.has('foo')); + assert.equal(captured.get('foo'), 'sentinel-old'); + el.remove(); + }); + + test('updateComplete resolves to true when nothing more is pending', async () => { + class E extends WebComponent { + static properties = { v: { type: Number } }; + constructor() { super(); this.v = 0; } + render() { return html`${this.v}
`; } + } + const t = tag('lp-uc-true'); + customElements.define(t, E); + const el = document.createElement(t); + document.body.appendChild(el); + const settled = await el.updateComplete; + assert.equal(settled, true); + el.remove(); + }); + + // ─────────────────────────────────────────────────────────────────────── + // shouldUpdate gate + // ─────────────────────────────────────────────────────────────────────── + + test('shouldUpdate controls whether update runs', async () => { + let allow = true; + let updates = 0; + class E extends WebComponent { + static properties = { foo: { type: Number } }; + constructor() { super(); this.foo = 0; } + shouldUpdate() { return allow; } + update(cp) { updates++; super.update(cp); } + render() { return html`${this.foo}
`; } + } + const t = tag('lp-su-control'); + customElements.define(t, E); + const el = document.createElement(t); + document.body.appendChild(el); + await el.updateComplete; + assert.equal(updates, 1, 'initial update ran'); + + allow = false; + el.foo = 1; + await el.updateComplete; + assert.equal(updates, 1, 'gated update did not run update()'); + + allow = true; + el.foo = 2; + await el.updateComplete; + assert.equal(updates, 2, 'ungated update ran'); + el.remove(); + }); + + test('firstUpdated still fires the first time update commits, even if shouldUpdate=false initially', async () => { + // Ported intent: lit fires firstUpdated when the FIRST actual update + // commits, not when the FIRST scheduled render is requested. + let firsts = 0; + let allow = false; + class E extends WebComponent { + static properties = { foo: { type: Number } }; + constructor() { super(); this.foo = 0; } + shouldUpdate() { return allow; } + firstUpdated() { firsts++; } + render() { return html`${this.foo}
`; } + } + const t = tag('lp-fu-first'); + customElements.define(t, E); + const el = document.createElement(t); + document.body.appendChild(el); + await el.updateComplete; + assert.equal(firsts, 0, 'firstUpdated did not fire while gated'); + + allow = true; + el.foo = 1; + await el.updateComplete; + assert.equal(firsts, 1, 'firstUpdated fires on the first committed render'); + + el.foo = 2; + await el.updateComplete; + assert.equal(firsts, 1, 'firstUpdated stays at 1 across later renders'); + el.remove(); + }); + + // ─────────────────────────────────────────────────────────────────────── + // willUpdate folds into current cycle + // ─────────────────────────────────────────────────────────────────────── + + test('willUpdate may mutate properties without triggering a second cycle', async () => { + let renders = 0; + class E extends WebComponent { + static properties = { + foo: { type: Number }, + bar: { type: Number, state: true }, + }; + constructor() { super(); this.foo = 0; this.bar = -1; } + willUpdate(cp) { + if (cp.has('foo')) this.bar = this.foo + 100; + } + render() { renders++; return html`${this.foo}/${this.bar}
`; } + } + const t = tag('lp-wu-fold'); + customElements.define(t, E); + const el = document.createElement(t); + document.body.appendChild(el); + await el.updateComplete; + const baseline = renders; + el.foo = 7; + await el.updateComplete; + assert.equal(renders - baseline, 1, 'exactly one new render'); + assert.equal(el.bar, 107); + el.remove(); + }); + + test('willUpdate-mutated property appears in changedProperties for the same cycle', async () => { + let captured; + class E extends WebComponent { + static properties = { + foo: { type: Number }, + derived: { type: Number, state: true }, + }; + constructor() { super(); this.foo = 0; this.derived = 0; } + willUpdate(cp) { + if (cp.has('foo')) this.derived = this.foo * 10; + } + updated(cp) { captured = new Map(cp); } + render() { return html`${this.derived}
`; } + } + const t = tag('lp-wu-cp'); + customElements.define(t, E); + const el = document.createElement(t); + document.body.appendChild(el); + await el.updateComplete; + + el.foo = 5; + await el.updateComplete; + assert.ok(captured.has('foo'), 'foo in changedProperties'); + assert.ok(captured.has('derived'), 'willUpdate-mutated derived in changedProperties'); + el.remove(); + }); + + // ─────────────────────────────────────────────────────────────────────── + // update() default + override semantics + // ─────────────────────────────────────────────────────────────────────── + + test('overriding update without calling super.update skips the commit', async () => { + // Webjs default update() calls render() + commits. An override that + // doesn't call super should still let updated()/firstUpdated() fire + // (because didCommit is set by entry to the cycle), but no DOM should + // be committed. + class E extends WebComponent { + static properties = { foo: { type: Number } }; + constructor() { super(); this.foo = 0; } + update(_cp) { /* intentionally no super.update */ } + render() { return html`should-not-appear-${this.foo}
`; } + } + const t = tag('lp-up-nosuper'); + customElements.define(t, E); + const el = document.createElement(t); + document.body.appendChild(el); + await el.updateComplete; + // Nocommitted. + assert.equal(el.querySelector('p'), null, 'no DOM was committed'); + el.remove(); + }); + + test('overriding update + calling super commits DOM and updated() observes it', async () => { + let updatedCp; + class E extends WebComponent { + static properties = { foo: { type: Number } }; + constructor() { super(); this.foo = 0; } + update(cp) { + // mutate AFTER super: per lit semantics this triggers another cycle + super.update(cp); + } + updated(cp) { updatedCp = new Map(cp); } + render() { return html`
${this.foo}
`; } + } + const t = tag('lp-up-super'); + customElements.define(t, E); + const el = document.createElement(t); + document.body.appendChild(el); + await el.updateComplete; + assert.equal(el.querySelector('p').textContent, '0'); + assert.ok(updatedCp); + el.remove(); + }); + + // ─────────────────────────────────────────────────────────────────────── + // updated runs every cycle, firstUpdated runs once + // ─────────────────────────────────────────────────────────────────────── + + test('updated runs every render commit; firstUpdated runs exactly once', async () => { + let firsts = 0; + let updates = 0; + class E extends WebComponent { + static properties = { v: { type: Number } }; + constructor() { super(); this.v = 0; } + firstUpdated() { firsts++; } + updated() { updates++; } + render() { return html`${this.v}
`; } + } + const t = tag('lp-fu-many'); + customElements.define(t, E); + const el = document.createElement(t); + document.body.appendChild(el); + await el.updateComplete; + assert.equal(firsts, 1); + assert.equal(updates, 1); + el.v = 1; await el.updateComplete; + el.v = 2; await el.updateComplete; + el.v = 3; await el.updateComplete; + assert.equal(firsts, 1); + assert.equal(updates, 4); + el.remove(); + }); + + // ─────────────────────────────────────────────────────────────────────── + // Hook ordering: shouldUpdate → willUpdate → hostUpdate → update → + // hostUpdated → firstUpdated → updated + // ─────────────────────────────────────────────────────────────────────── + + test('update lifecycle order (incl. controllers + post-commit hooks)', async () => { + const order = []; + const controller = { + hostConnected() { order.push('hostConnected'); }, + hostUpdate() { order.push('hostUpdate'); }, + hostUpdated() { order.push('hostUpdated'); }, + hostDisconnected() { order.push('hostDisconnected'); }, + }; + class E extends WebComponent { + static properties = { foo: { type: Number } }; + constructor() { super(); this.foo = 0; this.addController(controller); } + shouldUpdate() { order.push('shouldUpdate'); return true; } + willUpdate() { order.push('willUpdate'); } + update(cp) { order.push('before-update'); super.update(cp); order.push('after-update'); } + firstUpdated() { order.push('firstUpdated'); } + updated() { order.push('updated'); } + render() { return html`${this.foo}
`; } + } + const t = tag('lp-order'); + customElements.define(t, E); + const el = document.createElement(t); + document.body.appendChild(el); + await el.updateComplete; + order.push('updateComplete'); + + assert.deepEqual(order, [ + 'hostConnected', + 'shouldUpdate', + 'willUpdate', + 'hostUpdate', + 'before-update', + 'after-update', + 'hostUpdated', + 'firstUpdated', + 'updated', + 'updateComplete', + ]); + el.remove(); + }); + + // ─────────────────────────────────────────────────────────────────────── + // changedProperties Map: keys, old values, accumulation + // ─────────────────────────────────────────────────────────────────────── + + test('changedProperties has only initial keys on the first render with undefined olds', async () => { + let cpSnapshot; + class E extends WebComponent { + static properties = { foo: { type: Number }, bar: { type: String } }; + constructor() { super(); this.foo = 1; this.bar = 'x'; } + updated(cp) { cpSnapshot = new Map(cp); } + render() { return html`${this.foo}-${this.bar}
`; } + } + const t = tag('lp-cp-initial'); + customElements.define(t, E); + const el = document.createElement(t); + document.body.appendChild(el); + await el.updateComplete; + // Both initial values should be in the map. + assert.ok(cpSnapshot.has('foo')); + assert.ok(cpSnapshot.has('bar')); + assert.equal(cpSnapshot.get('foo'), undefined); + assert.equal(cpSnapshot.get('bar'), undefined); + el.remove(); + }); + + test('subsequent renders record only the changed key with the prior value', async () => { + let cp; + class E extends WebComponent { + static properties = { a: { type: Number }, b: { type: Number } }; + constructor() { super(); this.a = 1; this.b = 2; } + updated(c) { cp = new Map(c); } + render() { return html`${this.a}/${this.b}
`; } + } + const t = tag('lp-cp-subseq'); + customElements.define(t, E); + const el = document.createElement(t); + document.body.appendChild(el); + await el.updateComplete; + + el.a = 5; + await el.updateComplete; + assert.ok(cp.has('a')); + assert.equal(cp.get('a'), 1, 'oldValue is the prior value'); + assert.equal(cp.has('b'), false, 'unchanged prop NOT in map'); + el.remove(); + }); + + test('changedProperties is fresh per cycle (not cumulative across renders)', async () => { + const snapshots = []; + class E extends WebComponent { + static properties = { a: { type: Number }, b: { type: Number } }; + constructor() { super(); this.a = 0; this.b = 0; } + updated(cp) { snapshots.push([...cp.keys()].sort()); } + render() { return html`${this.a}/${this.b}
`; } + } + const t = tag('lp-cp-fresh'); + customElements.define(t, E); + const el = document.createElement(t); + document.body.appendChild(el); + await el.updateComplete; + el.a = 1; await el.updateComplete; + el.b = 2; await el.updateComplete; + // Cycle 1: both initial keys. Cycle 2: ['a']. Cycle 3: ['b']. + assert.deepEqual(snapshots[0], ['a', 'b']); + assert.deepEqual(snapshots[1], ['a']); + assert.deepEqual(snapshots[2], ['b']); + el.remove(); + }); + + test('batching: two synchronous property writes coalesce into one cycle', async () => { + let renders = 0; + let cp; + class E extends WebComponent { + static properties = { a: { type: Number }, b: { type: Number } }; + constructor() { super(); this.a = 0; this.b = 0; } + updated(c) { cp = new Map(c); } + render() { renders++; return html`${this.a}/${this.b}
`; } + } + const t = tag('lp-batch'); + customElements.define(t, E); + const el = document.createElement(t); + document.body.appendChild(el); + await el.updateComplete; + const baseline = renders; + + el.a = 7; + el.b = 8; + await el.updateComplete; + assert.equal(renders - baseline, 1, 'two writes batch into one render'); + assert.ok(cp.has('a') && cp.has('b')); + el.remove(); + }); + + test('shouldUpdate=false preserves changedProperties for the next cycle (lit parity)', async () => { + // Lit: when shouldUpdate returns false, changedProperties is NOT cleared, + // so the next requestUpdate sees the accumulated entries. + let allow = false; // gate from the very first cycle + const seen = []; + class E extends WebComponent { + static properties = { a: { type: Number }, b: { type: Number } }; + constructor() { super(); this.a = 0; this.b = 0; } + shouldUpdate() { return allow; } + updated(cp) { seen.push([...cp.keys()].sort()); } + render() { return html`${this.a}/${this.b}
`; } + } + const t = tag('lp-su-preserve'); + customElements.define(t, E); + const el = document.createElement(t); + document.body.appendChild(el); + await el.updateComplete; // gated: updated() not called + assert.deepEqual(seen, []); + + el.a = 1; + await el.updateComplete; + el.b = 2; + await el.updateComplete; + assert.deepEqual(seen, [], 'still gated'); + + allow = true; + el.a = 3; + await el.updateComplete; + assert.equal(seen.length, 1, 'one cycle ran since the gate opened'); + const keys = seen[0]; + assert.ok(keys.includes('a'), 'a was carried through'); + assert.ok(keys.includes('b'), 'b was carried through'); + el.remove(); + }); + + // ─────────────────────────────────────────────────────────────────────── + // Property reactivity: type, reflect, state, hasChanged, converter + // ─────────────────────────────────────────────────────────────────────── + + test('type: Number coerces attribute', async () => { + class E extends WebComponent { + static properties = { count: { type: Number } }; + render() { return html`${this.count}
`; } + } + const t = tag('lp-type-num'); + customElements.define(t, E); + const el = document.createElement(t); + el.setAttribute('count', '42'); + document.body.appendChild(el); + await el.updateComplete; + assert.equal(el.count, 42); + assert.equal(typeof el.count, 'number'); + el.remove(); + }); + + test('type: Boolean coerces attribute presence', async () => { + class E extends WebComponent { + static properties = { open: { type: Boolean } }; + render() { return html`${String(this.open)}
`; } + } + const t = tag('lp-type-bool'); + customElements.define(t, E); + const el = document.createElement(t); + el.setAttribute('open', ''); + document.body.appendChild(el); + await el.updateComplete; + assert.equal(el.open, true); + el.removeAttribute('open'); + await el.updateComplete; + assert.equal(el.open, false); + el.remove(); + }); + + test('type: Object parses JSON attribute', async () => { + class E extends WebComponent { + static properties = { data: { type: Object } }; + render() { return html`${this.data && this.data.name}
`; } + } + const t = tag('lp-type-obj'); + customElements.define(t, E); + const el = document.createElement(t); + el.setAttribute('data', '{"name":"alice","age":30}'); + document.body.appendChild(el); + await el.updateComplete; + assert.equal(el.data.name, 'alice'); + assert.equal(el.data.age, 30); + el.remove(); + }); + + test('reflect: true writes property changes back to the attribute', async () => { + class E extends WebComponent { + static properties = { count: { type: Number, reflect: true } }; + constructor() { super(); this.count = 0; } + render() { return html`${this.count}
`; } + } + const t = tag('lp-reflect'); + customElements.define(t, E); + const el = document.createElement(t); + document.body.appendChild(el); + await el.updateComplete; + el.count = 5; + await el.updateComplete; + assert.equal(el.getAttribute('count'), '5'); + el.remove(); + }); + + test('reflect: true with Boolean toggles attribute presence', async () => { + class E extends WebComponent { + static properties = { open: { type: Boolean, reflect: true } }; + constructor() { super(); this.open = false; } + render() { return html`${String(this.open)}
`; } + } + const t = tag('lp-reflect-bool'); + customElements.define(t, E); + const el = document.createElement(t); + document.body.appendChild(el); + await el.updateComplete; + assert.equal(el.hasAttribute('open'), false); + el.open = true; + await el.updateComplete; + assert.equal(el.hasAttribute('open'), true); + el.open = false; + await el.updateComplete; + assert.equal(el.hasAttribute('open'), false); + el.remove(); + }); + + test('state: true excludes the property from observedAttributes', () => { + class E extends WebComponent { + static properties = { + pub: { type: String }, + priv: { type: String, state: true }, + }; + render() { return html``; } + } + const observed = E.observedAttributes; + assert.ok(observed.includes('pub')); + assert.equal(observed.includes('priv'), false); + }); + + test('hasChanged: false skips the update', async () => { + let renders = 0; + class E extends WebComponent { + static properties = { + // Treat undefined as "always different" so the initial assignment + // actually lands (otherwise hasChanged(n, undefined) -> NaN > 1 -> false + // and the constructor's `this.size = 10` is rejected). + size: { + type: Number, + hasChanged: (n, o) => o === undefined || Math.abs(n - o) > 1, + }, + }; + constructor() { super(); this.size = 10; } + render() { renders++; return html`${this.size}
`; } + } + const t = tag('lp-haschanged'); + customElements.define(t, E); + const el = document.createElement(t); + document.body.appendChild(el); + await el.updateComplete; + const baseline = renders; + assert.equal(el.size, 10); + + el.size = 10.5; // delta < 1: no update + await el.updateComplete; + assert.equal(renders, baseline, 'small change skipped'); + assert.equal(el.size, 10, 'value not stored on a skipped change'); + + el.size = 20; // delta > 1: updates + await el.updateComplete; + assert.equal(renders, baseline + 1); + el.remove(); + }); + + test('converter.fromAttribute customizes attribute → property coercion', async () => { + class E extends WebComponent { + static properties = { + list: { + converter: { fromAttribute: (v) => v ? v.split(',').map(Number) : [] }, + }, + }; + render() { return html`${this.list && this.list.join('|')}
`; } + } + const t = tag('lp-conv-from'); + customElements.define(t, E); + const el = document.createElement(t); + el.setAttribute('list', '1,2,3'); + document.body.appendChild(el); + await el.updateComplete; + assert.deepEqual(el.list, [1, 2, 3]); + el.remove(); + }); + + test('converter.toAttribute customizes property → attribute reflection', async () => { + class E extends WebComponent { + static properties = { + coords: { + reflect: true, + converter: { + fromAttribute: (v) => v ? v.split(',').map(Number) : null, + toAttribute: (v) => v ? v.join(',') : null, + }, + }, + }; + constructor() { super(); this.coords = null; } + render() { return html``; } + } + const t = tag('lp-conv-to'); + customElements.define(t, E); + const el = document.createElement(t); + document.body.appendChild(el); + await el.updateComplete; + el.coords = [3, 4]; + await el.updateComplete; + assert.equal(el.getAttribute('coords'), '3,4'); + el.coords = null; + await el.updateComplete; + assert.equal(el.hasAttribute('coords'), false); + el.remove(); + }); + + // ─────────────────────────────────────────────────────────────────────── + // attributeChangedCallback → changedProperties + // ─────────────────────────────────────────────────────────────────────── + + test('attribute change flows through to property + changedProperties', async () => { + let lastCp; + class E extends WebComponent { + static properties = { foo: { type: String } }; + constructor() { super(); this.foo = 'a'; } + updated(cp) { lastCp = new Map(cp); } + render() { return html`${this.foo}
`; } + } + const t = tag('lp-acc'); + customElements.define(t, E); + const el = document.createElement(t); + document.body.appendChild(el); + await el.updateComplete; + + el.setAttribute('foo', 'b'); + await el.updateComplete; + assert.equal(el.foo, 'b'); + assert.ok(lastCp.has('foo')); + el.remove(); + }); + + // ─────────────────────────────────────────────────────────────────────── + // setState integration + // ─────────────────────────────────────────────────────────────────────── + + test('setState patches shallow-merge into this.state and schedule a render', async () => { + class E extends WebComponent { + constructor() { super(); this.state = { count: 0, name: 'x' }; } + render() { return html`${this.state.count}:${this.state.name}
`; } + } + const t = tag('lp-ss-merge'); + customElements.define(t, E); + const el = document.createElement(t); + document.body.appendChild(el); + await el.updateComplete; + assert.equal(el.querySelector('p').textContent, '0:x'); + + el.setState({ count: 5 }); + await el.updateComplete; + assert.equal(el.state.count, 5); + assert.equal(el.state.name, 'x', 'unspecified state key preserved'); + assert.equal(el.querySelector('p').textContent, '5:x'); + el.remove(); + }); + + test("setState adds a 'state' entry to changedProperties with prior bag as old value", async () => { + let captured; + class E extends WebComponent { + constructor() { super(); this.state = { count: 1 }; } + updated(cp) { if (cp.has('state')) captured = cp.get('state'); } + render() { return html`${this.state.count}
`; } + } + const t = tag('lp-ss-cp'); + customElements.define(t, E); + const el = document.createElement(t); + document.body.appendChild(el); + await el.updateComplete; + captured = null; + el.setState({ count: 2 }); + await el.updateComplete; + assert.deepEqual(captured, { count: 1 }); + el.remove(); + }); + + test('two setState calls within one tick collapse into one render with the FIRST prior bag', async () => { + let captured; + let renders = 0; + class E extends WebComponent { + constructor() { super(); this.state = { a: 1 }; } + updated(cp) { if (cp.has('state')) captured = cp.get('state'); } + render() { renders++; return html`${JSON.stringify(this.state)}
`; } + } + const t = tag('lp-ss-batch'); + customElements.define(t, E); + const el = document.createElement(t); + document.body.appendChild(el); + await el.updateComplete; + const baseline = renders; + + el.setState({ a: 2 }); + el.setState({ a: 3 }); + await el.updateComplete; + assert.equal(renders - baseline, 1, 'two setState collapse into one render'); + // The 'state' entry's old value should be the original state (before either patch). + assert.deepEqual(captured, { a: 1 }); + assert.equal(el.state.a, 3); + el.remove(); + }); + + // ─────────────────────────────────────────────────────────────────────── + // updateComplete advanced semantics + // ─────────────────────────────────────────────────────────────────────── + + test('setting properties in updated() schedules another cycle; updateComplete eventually returns true', async () => { + // Webjs intentional divergence from lit: webjs schedules the follow-up + // cycle via raw queueMicrotask, which runs BEFORE the await-continuation + // of the resolving updateComplete promise. So a single `await updateComplete` + // may observe more than one extra cycle having already run. Lit chains the + // next cycle through `await __updatePromise`, gating it on the awaiter. + // We assert the eventual fixed-point only. + let updates = 0; + class E extends WebComponent { + static properties = { foo: { type: Number } }; + constructor() { super(); this.foo = 0; } + update(cp) { updates++; super.update(cp); } + updated() { if (this.foo < 2) this.foo++; } + render() { return html`${this.foo}
`; } + } + const t = tag('lp-uc-updated-sched'); + customElements.define(t, E); + const el = document.createElement(t); + document.body.appendChild(el); + let safety = 50; + while (!(await el.updateComplete) && safety-- > 0) { /* spin until settled */ } + assert.equal(el.foo, 2, 'fixed point reached'); + assert.ok(updates >= 3, 'at least 3 update() calls ran'); + el.remove(); + }); + + test('updateComplete can be awaited in a loop until it returns true', async () => { + class E extends WebComponent { + static properties = { foo: { type: Number } }; + constructor() { super(); this.foo = 0; } + updated() { if (this.foo < 5) this.foo++; } + render() { return html`${this.foo}
`; } + } + const t = tag('lp-uc-loop'); + customElements.define(t, E); + const el = document.createElement(t); + document.body.appendChild(el); + let safety = 50; + while (!(await el.updateComplete) && safety-- > 0) { /* spin */ } + assert.equal(el.foo, 5); + el.remove(); + }); + + test('getUpdateComplete override can chain additional async work', async () => { + let extraDone = false; + class E extends WebComponent { + static properties = { v: { type: Number } }; + constructor() { super(); this.v = 0; } + async getUpdateComplete() { + const r = await super.getUpdateComplete(); + await new Promise(res => setTimeout(res, 5)); + extraDone = true; + return r; + } + render() { return html`${this.v}
`; } + } + const t = tag('lp-uc-extra'); + customElements.define(t, E); + const el = document.createElement(t); + document.body.appendChild(el); + await el.updateComplete; + assert.ok(extraDone); + el.remove(); + }); + + test('updateComplete promise lifecycle: same promise across pending updates, new after settle', async () => { + class E extends WebComponent { + static properties = { v: { type: Number } }; + constructor() { super(); this.v = 0; } + render() { return html`${this.v}
`; } + } + const t = tag('lp-uc-promise'); + customElements.define(t, E); + const el = document.createElement(t); + document.body.appendChild(el); + const p1 = el.updateComplete; + const p2 = el.updateComplete; + assert.equal(p1, p2, 'same pending promise'); + await p1; + el.v = 1; + const p3 = el.updateComplete; + assert.notEqual(p1, p3, 'new cycle produces a new promise'); + await p3; + el.remove(); + }); + + // ─────────────────────────────────────────────────────────────────────── + // Error recovery: a throwing hook does NOT deadlock + // ─────────────────────────────────────────────────────────────────────── + + test('throwing willUpdate does not deadlock: next requestUpdate still renders', async () => { + await withSilencedErrors(async () => { + let throws = true; + class E extends WebComponent { + static properties = { v: { type: Number } }; + constructor() { super(); this.v = 0; } + willUpdate() { + if (throws) { throws = false; throw new Error('willUpdate boom'); } + } + render() { return html`v=${this.v}
`; } + } + const t = tag('lp-err-will'); + customElements.define(t, E); + const el = document.createElement(t); + document.body.appendChild(el); + + // Promise resolves despite throw (no deadlock). + let resolved = false; + const timed = Promise.race([ + el.updateComplete.then(() => { resolved = true; }), + new Promise((_, rej) => setTimeout(() => rej(new Error('deadlock')), 200)), + ]); + await timed; + assert.ok(resolved); + + el.v = 7; + await el.updateComplete; + assert.equal(el.querySelector('p').textContent, 'v=7'); + el.remove(); + }); + }); + + test('throwing updated does not deadlock', async () => { + await withSilencedErrors(async () => { + let throws = true; + class E extends WebComponent { + static properties = { v: { type: Number } }; + constructor() { super(); this.v = 0; } + updated() { + if (throws) { throws = false; throw new Error('updated boom'); } + } + render() { return html`v=${this.v}
`; } + } + const t = tag('lp-err-updated'); + customElements.define(t, E); + const el = document.createElement(t); + document.body.appendChild(el); + + let resolved = false; + await Promise.race([ + el.updateComplete.then(() => { resolved = true; }), + new Promise((_, rej) => setTimeout(() => rej(new Error('deadlock')), 200)), + ]); + assert.ok(resolved); + el.v = 9; + await el.updateComplete; + assert.equal(el.querySelector('p').textContent, 'v=9'); + el.remove(); + }); + }); + + test('throwing firstUpdated does not deadlock', async () => { + await withSilencedErrors(async () => { + let throws = true; + class E extends WebComponent { + static properties = { v: { type: Number } }; + constructor() { super(); this.v = 0; } + firstUpdated() { + if (throws) { throws = false; throw new Error('firstUpdated boom'); } + } + render() { return html`v=${this.v}
`; } + } + const t = tag('lp-err-first'); + customElements.define(t, E); + const el = document.createElement(t); + document.body.appendChild(el); + + let resolved = false; + await Promise.race([ + el.updateComplete.then(() => { resolved = true; }), + new Promise((_, rej) => setTimeout(() => rej(new Error('deadlock')), 200)), + ]); + assert.ok(resolved); + // Subsequent updates work; firstUpdated should NOT re-fire. + let firstReran = false; + Object.defineProperty(E.prototype, 'firstUpdated', { + configurable: true, + value: () => { firstReran = true; }, + }); + el.v = 3; + await el.updateComplete; + assert.equal(firstReran, false, 'firstUpdated does not re-fire after the throw'); + el.remove(); + }); + }); + + test('throwing render() routes to renderError() fallback and does not deadlock', async () => { + await withSilencedErrors(async () => { + let throws = true; + class E extends WebComponent { + static properties = { v: { type: Number } }; + constructor() { super(); this.v = 0; } + render() { + if (throws) { throws = false; throw new Error('render boom'); } + return html`ok-${this.v}
`; + } + renderError(e) { return html`err:${e.message}
`; } + } + const t = tag('lp-err-render'); + customElements.define(t, E); + const el = document.createElement(t); + document.body.appendChild(el); + await el.updateComplete; + assert.ok(el.querySelector('.err'), 'fallback was rendered'); + // Recovery: next update should re-render successfully. + el.v = 2; + await el.updateComplete; + assert.equal(el.querySelector('p').textContent, 'ok-2'); + el.remove(); + }); + }); + + // ─────────────────────────────────────────────────────────────────────── + // _isUpdating: re-entrant requestUpdate during the update phase folds + // ─────────────────────────────────────────────────────────────────────── + + test('requestUpdate during willUpdate folds into current cycle (no extra microtask)', async () => { + let renders = 0; + class E extends WebComponent { + static properties = { a: { type: Number }, b: { type: Number, state: true } }; + constructor() { super(); this.a = 0; this.b = 0; } + willUpdate(cp) { + if (cp.has('a')) this.requestUpdate('b', this.b); + } + render() { renders++; return html`${this.a}/${this.b}
`; } + } + const t = tag('lp-isupdating-will'); + customElements.define(t, E); + const el = document.createElement(t); + document.body.appendChild(el); + await el.updateComplete; + const baseline = renders; + el.a = 5; + await el.updateComplete; + assert.equal(renders - baseline, 1, 'exactly one new render'); + el.remove(); + }); + + test('requestUpdate inside updated() (after _isUpdating clears) schedules a NEW cycle', async () => { + // After updated() runs, _isUpdating has been cleared, so a fresh + // requestUpdate() must enqueue a new microtask render (not fold into + // the cycle that's just settled). + let renders = 0; + let scheduled = false; + class E extends WebComponent { + static properties = { v: { type: Number } }; + constructor() { super(); this.v = 0; } + updated() { + if (!scheduled) { + scheduled = true; + this.requestUpdate(); + } + } + render() { renders++; return html`${this.v}
`; } + } + const t = tag('lp-isupdating-updated'); + customElements.define(t, E); + const el = document.createElement(t); + document.body.appendChild(el); + // Spin until settled; webjs's scheduler may race the next microtask + // ahead of the awaiter so we don't pin the first-await return value. + let safety = 20; + while (!(await el.updateComplete) && safety-- > 0) { /* spin */ } + assert.ok(renders >= 2, 'updated() did schedule a follow-up render'); + el.remove(); + }); + + // ─────────────────────────────────────────────────────────────────────── + // Disconnected behavior + // ─────────────────────────────────────────────────────────────────────── + + test('update does not occur before connect; scheduled updates run on connection', async () => { + let renders = 0; + class E extends WebComponent { + static properties = { v: { type: Number } }; + constructor() { super(); this.v = 0; } + render() { renders++; return html`${this.v}
`; } + } + const t = tag('lp-disconn'); + customElements.define(t, E); + const el = document.createElement(t); + // Set before connecting; the scheduler should not fire render() yet. + el.v = 42; + // Yield a microtask to confirm no render happens off-DOM. + await Promise.resolve(); + assert.equal(renders, 0); + document.body.appendChild(el); + await el.updateComplete; + assert.ok(renders >= 1); + el.remove(); + }); + + // ─────────────────────────────────────────────────────────────────────── + // Sub-element updateComplete (composition) + // ─────────────────────────────────────────────────────────────────────── + + test('can await a sub-element updateComplete from getUpdateComplete', async () => { + class Child extends WebComponent { + static properties = { x: { type: Number } }; + constructor() { super(); this.x = 0; } + render() { return html`${this.x}`; } + } + customElements.define('lp-sub-child', Child); + + class Parent extends WebComponent { + static properties = { v: { type: Number } }; + constructor() { super(); this.v = 0; } + async getUpdateComplete() { + const r = await super.getUpdateComplete(); + const child = this.querySelector('lp-sub-child'); + if (child) await child.updateComplete; + return r; + } + render() { return html`hi
`; + const r = keyed('k1', tpl); + assert.equal(r._$webjs, 'keyed'); + assert.equal(r.key, 'k1'); + assert.equal(r.value, tpl); +}); + +test('isKeyed: detects markers', () => { + assert.ok(isKeyed(keyed('k', null))); + assert.ok(!isKeyed({ _$webjs: 'live' })); + assert.ok(!isKeyed(null)); +}); + +test('keyed: SSR renders the wrapped template', async () => { + const result = await renderToString(html`hello
`)}hello
'), `Expected wrapped template, got: ${result}`); +}); + +// --- guard (lit-html parity) --- + +test('guard: creates marker with correct shape', () => { + const fn = () => 'x'; + const r = guard([1, 2], fn); + assert.equal(r._$webjs, 'guard'); + assert.deepEqual(r.deps, [1, 2]); + assert.equal(r.fn, fn); +}); + +test('isGuard: detects markers', () => { + assert.ok(isGuard(guard([], () => null))); + assert.ok(!isGuard({ _$webjs: 'live' })); +}); + +test('guard: SSR always invokes the function', async () => { + let calls = 0; + const result = await renderToString( + html`v
`; })}v
')); +}); + +// --- templateContent (lit-html parity) --- + +test('templateContent: SSR emits the template element innerHTML', async () => { + const fakeTpl = { innerHTML: 'cloned' }; + const result = await renderToString(html`v
`); + assert.equal(r._$webjs, 'cache'); +}); + +test('isCache: detects markers', () => { + assert.ok(isCache(cache(null))); + assert.ok(!isCache({ _$webjs: 'live' })); +}); + +test('cache: SSR passes through to the inner value', async () => { + const result = await renderToString(html`hello
`)}hello
')); +}); + +// --- until (lit-html parity) --- + +test('until: creates marker', () => { + const r = until('a', 'b'); + assert.equal(r._$webjs, 'until'); + assert.deepEqual(r.args, ['a', 'b']); +}); + +test('isUntil: detects markers', () => { + assert.ok(isUntil(until('x'))); + assert.ok(!isUntil({ _$webjs: 'live' })); +}); + +test('until: SSR renders first synchronous candidate', async () => { + const promise = new Promise(() => {}); // never resolves + const result = await renderToString(html`guarded
`)} + ${cache(html``)} + +${until(new Promise(() => {}), 'fallback-only')}
+