From a0a120f31cd00bfb4cb35753b3e17a5dca8d0e16 Mon Sep 17 00:00:00 2001 From: Kevin Schaaf Date: Tue, 23 Jun 2020 10:20:57 -0700 Subject: [PATCH] Experimental client+server-renderable directives (repeat & cache) (#50) * Initial client+server-renderable directives (repeat & cache) * Add guard directive test * Add support for directives in AttributeParts. Add classMap test. * Add styleMap tests (& classMap statics test) * Add `isServerRendering` render option * Respect `noChange` returned from attribute committer * Add until directive tests * Enable noChange tests in AttributeParts * Re-enable attribute part tests * Re-enable AttributePart array test now that committer.getValue is used * Add asyncAppend/Repeat directive tests. Includes test support for async check and closures in test definition, to allow for stateful arguments to render. Updated until tests to take advantage of this. * Add ifDefined directive tests. * Add "manual" test configuration * Add live directive test. * Add guard directive test (for AttributePart) * Add PropertyPart directive tests. * Add BooleanPart directive support & tests. * Add EventPart directive tests. * Add "reflected" PropertyPart tests * Add support for directives in reflected PropertyParts * Pass isServerRendering option to BooleanAttributePart * Thread good error message through from reader * Combine debug/manual back into one launch config * Cleanup comments. * Add SSR subclasses of Parts/Committers. - Throws on access to DOM - Sets isServerRendering flag * Update to use NodePart:getPendingValue * Name improvement. * Use resolvePendingDirective(). * Add test for all part types at various depths. Fixes #37 * Add basic LitElement tests (#60) * Add LitElement tests. * Adds deep mutation obseving * Add ability for deep html expectations * Adds setup() callback that runs before render * Changes check() callback to run before the html expectation (to allow awaiting a promise before checking) * Fix example. * Add issue link --- .vscode/launch.json | 2 +- src/lib/directives/class-map.ts | 30 - src/lib/directives/repeat.ts | 43 - src/lib/import-module.ts | 13 +- src/lib/render-lit-html.ts | 213 +- src/lib/util/iterator-readable.ts | 5 +- src/test/integration/client/basic_test.ts | 161 +- src/test/integration/server/server.ts | 6 +- src/test/integration/test.ts | 5 +- src/test/integration/tests/basic.ts | 2386 ++++++++++++++++++++- src/test/integration/tests/ssr-test.ts | 18 +- src/test/test-files/render-test-module.ts | 4 +- 12 files changed, 2629 insertions(+), 257 deletions(-) delete mode 100644 src/lib/directives/class-map.ts delete mode 100644 src/lib/directives/repeat.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index af870db..9c89241 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -8,7 +8,7 @@ "type": "node", "runtimeVersion": "13.12.0", "request": "launch", - "name": "Test: Integration", + "name": "Test: Integration (debug)", "skipFiles": [ "/**" ], diff --git a/src/lib/directives/class-map.ts b/src/lib/directives/class-map.ts deleted file mode 100644 index 550a373..0000000 --- a/src/lib/directives/class-map.ts +++ /dev/null @@ -1,30 +0,0 @@ -export type ClassMapPreRenderer = () => string; - -const directives = new WeakMap(); - -export interface ClassInfo { - readonly [name: string]: string|boolean|number; -} - -/** - * Whether the given value is a classMap directive. - */ -export const isClassMapDirective = (value: unknown): boolean => { - return directives.has(value as ClassMapPreRenderer); -} - -/** - * classMap directive factory for server-side pre-rendering. - */ -export const classMap = (classInfo: ClassInfo): ClassMapPreRenderer => { - /** - * Returns a string of class names whose values in classInfo are truthy. - */ - const doClassMap = function(): string { - // We explicitly want a loose truthy check here to match the lit-html - // classMap implementation. - return Object.keys(classInfo).filter((name) => classInfo[name]).join(' '); - } - directives.set(doClassMap, true); - return doClassMap; -} diff --git a/src/lib/directives/repeat.ts b/src/lib/directives/repeat.ts deleted file mode 100644 index a22b3d5..0000000 --- a/src/lib/directives/repeat.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { renderValue, RenderInfo } from '../render-lit-html.js'; - -export type RepeatPreRenderer = (renderInfo: RenderInfo) => IterableIterator; - -const directives = new WeakMap(); - -/** - * Whether the given value is a repeat directive and can be called - * to pre-render its previously given items. - */ -export const isRepeatDirective = (value: unknown): boolean => { - return directives.has(value as RepeatPreRenderer); -} - -type KeyFn = (item: T, index: number) => unknown; -type ItemTemplate = (item: T, index: number) => unknown; - -/** - * The repeat directive factory for server-side pre-rendering. The directive - * is only used once per TemplateResult during pre-rending, so we do not need - * to make optimizations for reordering. - */ -export const repeat = (items: Iterable, - keyFnOrTemplate: KeyFn|ItemTemplate, - template?: ItemTemplate): RepeatPreRenderer => { - if (template === undefined) { - template = keyFnOrTemplate; - } - - /** - * The function that will be called upon to pre-render the list of items using - * the template function. - */ - const doRepeat = function* (renderInfo: RenderInfo): IterableIterator { - let i = 0; - for (const item of items) { - yield* renderValue(template!(item, i), renderInfo); - i++; - } - } - directives.set(doRepeat, true); - return doRepeat; -} diff --git a/src/lib/import-module.ts b/src/lib/import-module.ts index e5c684d..a1426c3 100644 --- a/src/lib/import-module.ts +++ b/src/lib/import-module.ts @@ -59,16 +59,9 @@ const resolveSpecifier = (specifier: string, referrer: string): URL => { } if (specifier.startsWith('lit-html')) { - if (specifier.match(/lit-html\/directives\/repeat\.js$/)) { - // Swap directives when requested. - return new URL(`file:${path.resolve('lib/directives/repeat.js')}`); - } else if (specifier.match(/lit-html\/directives\/class-map\.js$/)) { - return new URL(`file:${path.resolve('lib/directives/class-map.js')}`); - } else { - // Override where we resolve lit-html from so that we always resolve to - // a single version of lit-html. - referrer = import.meta.url; - } + // Override where we resolve lit-html from so that we always resolve to + // a single version of lit-html. + referrer = import.meta.url; } const referencingModulePath = new URL(referrer).pathname; const modulePath = resolve.sync(specifier, { diff --git a/src/lib/render-lit-html.ts b/src/lib/render-lit-html.ts index a519d31..10720b2 100644 --- a/src/lib/render-lit-html.ts +++ b/src/lib/render-lit-html.ts @@ -14,7 +14,18 @@ * http://polymer.github.io/PATENTS.txt */ -import {TemplateResult, nothing, noChange} from 'lit-html'; +import { + TemplateResult, + nothing, + noChange, + NodePart, + RenderOptions, + AttributeCommitter, + BooleanAttributePart, + PropertyCommitter, + AttributePart, + PropertyPart, +} from 'lit-html'; import { marker, markerRegex, @@ -34,14 +45,11 @@ import { import {CSSResult} from 'lit-element'; import StyleTransformer from '@webcomponents/shadycss/src/style-transformer.js'; -import {isRepeatDirective, RepeatPreRenderer} from './directives/repeat.js'; -import { - isClassMapDirective, - ClassMapPreRenderer, -} from './directives/class-map.js'; +import {isDirective} from 'lit-html/lib/directive.js'; import {isRenderLightDirective} from 'lit-element/lib/render-light.js'; import {LitElementRenderer} from './lit-element-renderer.js'; import {reflectedAttributeName} from './reflected-attributes.js'; +import { TemplateFactory } from 'lit-html/lib/template-factory'; declare module 'parse5' { interface DefaultTreeElement { @@ -56,6 +64,87 @@ const templateCache = new Map< } >(); +const directiveSSRError = (dom: string) => + `Directives must not access ${dom} during SSR; ` + + `directives must only call setValue() during initial render.` + +class SSRNodePart extends NodePart { + constructor(options: RenderOptions) { + super(options); + this.isServerRendering = true; + } + get startNode(): Element { + throw new Error(directiveSSRError('NodePart:startNode')); + } + set startNode(_v) {} + get endNode(): Element { + throw new Error(directiveSSRError('NodePart:endNode')); + } + set endNode(_v) {} +} + +class SSRAttributeCommitter extends AttributeCommitter { + constructor(name: string, strings: ReadonlyArray) { + super(undefined as any as Element, name, strings); + this.isServerRendering = true; + } + protected _createPart(): SSRAttributePart { + return new SSRAttributePart(this); + } + get element(): Element { + throw new Error(directiveSSRError('AttributeCommitter:element')); + } + set element(_v) {} +} + +class SSRAttributePart extends AttributePart { + constructor(committer: AttributeCommitter) { + super(committer); + this.isServerRendering = true; + } +} + +class SSRPropertyCommitter extends PropertyCommitter { + constructor(name: string, strings: ReadonlyArray) { + super(undefined as any as Element, name, strings); + this.isServerRendering = true; + } + protected _createPart(): SSRPropertyPart { + return new SSRPropertyPart(this); + } + get element(): Element { + throw new Error(directiveSSRError('PropertyCommitter:element')); + } + set element(_v) {} +} + +class SSRPropertyPart extends PropertyPart { + constructor(committer: PropertyCommitter) { + super(committer); + this.isServerRendering = true; + } +} + +class SSRBooleanAttributePart extends BooleanAttributePart { + constructor(name: string, strings: readonly string[]) { + super(undefined as any as Element, name, strings); + this.isServerRendering = true; + } + get element(): Element { + throw new Error(directiveSSRError('BooleanAttributePart:element')); + } + set element(_v) {} +} + +const ssrRenderOptions: RenderOptions = { + get templateFactory(): TemplateFactory { + throw new Error(directiveSSRError('RenderOptions:templateFactory')); + }, + get eventContext(): EventTarget { + throw new Error(directiveSSRError('RenderOptions:eventContext')); + } +}; + /** * Operation to output static text */ @@ -330,23 +419,26 @@ export function* renderValue( value: unknown, renderInfo: RenderInfo ): IterableIterator { + if (isRenderLightDirective(value)) { + // If a value was produced with renderLight(), we want to call and render + // the renderLight() method. + const instance = getLast(renderInfo.customElementInstanceStack); + // TODO, move out of here into something LitElement specific + if (instance !== undefined) { + yield* instance.renderLight(renderInfo); + } + value = null; + } else if (isDirective(value)) { + const part = new SSRNodePart(ssrRenderOptions); + part.setValue(value); + value = part.resolvePendingDirective(); + } if (value instanceof TemplateResult) { yield ``; yield* renderTemplateResult(value, renderInfo); } else { yield ``; - if (value === undefined || value === null) { - // do nothing - } else if (isRepeatDirective(value)) { - yield* (value as RepeatPreRenderer)(renderInfo); - } else if (isRenderLightDirective(value)) { - // If a value was produced with renderLight(), we want to call and render - // the renderLight() method. - const instance = getLast(renderInfo.customElementInstanceStack); - if (instance !== undefined) { - yield* instance.renderLight(renderInfo); - } - } else if (value === nothing || value === noChange) { + if (value === undefined || value === null || value === nothing || value === noChange) { // yield nothing } else if (Array.isArray(value)) { for (const item of value) { @@ -405,45 +497,66 @@ export function* renderTemplateResult( : undefined; if (prefix === '.') { const propertyName = name.substring(1); - const value = result.values[partIndex]; - if (instance !== undefined) { - instance.setProperty(propertyName, value); - } // Property should be reflected to attribute const reflectedName = reflectedAttributeName( op.tagName, propertyName ); - if (reflectedName !== undefined) { - yield `${reflectedName}="${value}"`; + // Property should be set to custom element instance + const instance = op.useCustomElementInstance + ? getLast(renderInfo.customElementInstanceStack) + : undefined; + if (instance || reflectedName !== undefined) { + const committer = new SSRPropertyCommitter( + attributeName, + statics); + committer.parts.forEach((part, i) => { + part.setValue(result.values[partIndex + i]); + part.resolvePendingDirective(); + }); + if (committer.dirty) { + const value = committer.getValue(); + if (value !== noChange) { + if (instance !== undefined) { + instance.setProperty(propertyName, value); + } + if (reflectedName !== undefined) { + // TODO: escape the attribute string + yield `${reflectedName}="${value}"`; + } + + } + } } } else if (prefix === '@') { // Event binding, do nothing with values } else if (prefix === '?') { // Boolean attribute binding attributeName = attributeName.substring(1); - if (statics.length !== 2 || statics[0] !== '' || statics[1] !== '') { - throw new Error( - 'Boolean attributes can only contain a single expression' - ); + const part = new SSRBooleanAttributePart( + attributeName, + statics); + part.setValue(result.values[partIndex]); + const value = part.resolvePendingDirective(); + if (value && value !== noChange) { + yield attributeName; } - const value = result.values[partIndex]; - if (value) { + } else { + const committer = new SSRAttributeCommitter( + attributeName, + statics); + committer.parts.forEach((part, i) => { + part.setValue(result.values[partIndex + i]); + part.resolvePendingDirective(); + }); + // TODO: escape the attribute string + const value = committer.getValue(); + if (value !== noChange) { if (instance !== undefined) { instance.setAttribute(attributeName, value as string); } - yield attributeName; - } - } else { - const attributeString = `${attributeName}="${getAttrValue( - statics, - result, - partIndex - )}"`; - if (instance !== undefined) { - instance.setAttribute(attributeName, attributeString as string); + yield `${attributeName}="${value}"`; } - yield attributeString; } partIndex += statics.length - 1; break; @@ -484,22 +597,4 @@ export function* renderTemplateResult( } } -const getAttrValue = ( - strings: ReadonlyArray, - result: TemplateResult, - startIndex: number -) => { - let s = strings[0]; - for (let i = 0; i < strings.length - 1; i++) { - const value = result.values[startIndex + i]; - if (isClassMapDirective(value)) { - s += (value as ClassMapPreRenderer)(); - } else { - s += String(value); - } - s += strings[i + 1]; - } - return s; -}; - const getLast = (a: Array) => a[a.length - 1]; diff --git a/src/lib/util/iterator-readable.ts b/src/lib/util/iterator-readable.ts index 308c4ef..1e9fdf4 100644 --- a/src/lib/util/iterator-readable.ts +++ b/src/lib/util/iterator-readable.ts @@ -30,7 +30,10 @@ export class IterableReader extends Readable { const r = this._iterator.next(); this.push(r.done ? null : r.value); } catch (e) { - this.emit('error', e); + // Because the error may be thrown across realms, it won't pass an + // `e instanceof Error` check in Koa's default error handling; instead + // propagate the error string so we can get some context at least + this.emit('error', e.stack.toString()); } } } diff --git a/src/test/integration/client/basic_test.ts b/src/test/integration/client/basic_test.ts index 11d505e..170a5ff 100644 --- a/src/test/integration/client/basic_test.ts +++ b/src/test/integration/client/basic_test.ts @@ -17,9 +17,79 @@ import '@open-wc/testing'; import {tests} from '../tests/basic.js'; import {render} from 'lit-html'; import {hydrate} from 'lit-html/lib/hydrate.js'; +import {hydrateShadowRoots} from 'template-shadowroot/template-shadowroot.js'; +import {SSRExpectedHTML} from '../tests/ssr-test.js'; +import {LitElement} from 'lit-element'; + +LitElement.hydrate = hydrate; const assert = chai.assert; +/** + * Checks a tree of expected HTML against the DOM; the expected HTML can either + * be a string or an object containing keys of querySelector queries mapped to + * HTML expectations for the shadow root of the returned element(s); if more + * than one element is returned from the querySelector, the value must be an + * array of HTML expectations. When using in tree mode, the string 'root' is + * used as a sentinel to test the container at a given level. + * + * Examples: + * + * // Test `container` html + * html: '
' + * + * // Test `container` and shadowRoot of `my-el` + * html: { + * root: '', + * 'my-el': '', + * + * // Test `container` and shadowRoot of `my-el`, and shadow root of my-el2 + * inside of it + * html: { + * root: '', + * 'my-el': { + * root: '' + * 'my-el2': '' + * }, + * + * // Test `container` and shadowRoot series of `my-el` + * html: { + * root: ` + * + * `, + * 'my-el': [ + * '
', + * '
' + * ], +*/ +const assertHTML = (container: Element | ShadowRoot, html: SSRExpectedHTML): void => { + if (typeof html !== 'object') { + assert.lightDom.equal(container, html); + } else { + for (const query in html) { + const subHtml = html[query]; + if (query === 'root') { + assert.typeOf(subHtml, 'string', `html expectation for ':root' must be a string.`); + assert.lightDom.equal(container, subHtml as string); + } else { + const subContainers = Array.from(container.querySelectorAll(query)) + ; + if (Array.isArray(subHtml)) { + assert.strictEqual(subContainers.length, subHtml.length, `Did not find expected number of elements for query '${query}'`); + subContainers.forEach((subContainer, i) => { + assert.instanceOf(subContainer.shadowRoot, ShadowRoot, `No shadowRoot for queried element '${query}[${i}]'`); + assertHTML(subContainer.shadowRoot!, subHtml[i]); + }); + } else { + assert.strictEqual(subContainers.length, 1, `Number of nodes found for element query '${query}'`); + assert.instanceOf(subContainers[0].shadowRoot, ShadowRoot, `No shadowRoot for queried element '${query}'`); + assertHTML(subContainers[0].shadowRoot!, subHtml); + } + } + } + } +} + suite('basic', () => { let container: HTMLElement; let observer: MutationObserver; @@ -35,24 +105,38 @@ suite('basic', () => { return _mutations; }; + const observeMutations = (container: Element) => { + observer.observe(container, { + attributes: true, + characterData: true, + childList: true, + subtree: true, + }); + // Deeply observe all roots + Array.from(container.querySelectorAll('*')) + .filter(el => el.shadowRoot) + .forEach(el => observeMutations(el)); + } + setup(() => { + // Container is appended to body so that CE upgrades run container = document.createElement('div'); + document.body.appendChild(container); _mutations = []; observer = new MutationObserver((records) => { _mutations.push(...records); }); clearMutations(); - observer.observe(container, { - attributes: true, - characterData: true, - childList: true, - subtree: true, - }); + }); + + teardown(() => { + document.body.removeChild(container); }); - for (const [testName, testSetup] of Object.entries(tests)) { - const {render: testRender, expectations, stableSelectors, expectMutationsOnFirstRender} = testSetup; + for (const [testName, testDescOrFn] of Object.entries(tests)) { + let testSetup = (typeof testDescOrFn === 'function') ? testDescOrFn() : testDescOrFn; + const {render: testRender, registerElements, expectations, stableSelectors, expectMutationsOnFirstRender, expectMutationsDuringHydration, expectMutationsDuringUpgrade} = testSetup; const testFn = testSetup.skip ? test.skip : testSetup.only ? test.only : test; @@ -62,46 +146,79 @@ suite('basic', () => { const response = await fetch(`/test/basic/${testName}`); container.innerHTML = await response.text(); + // For element tests, hydrate shadowRoots + if (typeof registerElements === 'function') { + hydrateShadowRoots(container); + } + + // Start watching for mutations (deeply into any shadowRoots) + observeMutations(container); + // The first expectation args are used in the server render. Check the DOM - // pre-hydration to make sure they're correct. The DOM is chaned again + // pre-hydration to make sure they're correct. The DOM is changed again // against the first expectation after hydration in the loop below. - assert.lightDom.equal(container, expectations[0].html); + assertHTML(container, expectations[0].html); const stableNodes = stableSelectors.map( (selector) => container.querySelector(selector)); clearMutations(); + // For element tests, register & upgrade/hydrate elements + if (typeof registerElements === 'function') { + await registerElements(); + assertHTML(container, expectations[0].html); + if (!expectMutationsDuringUpgrade) { + assert.isEmpty(getMutations(), 'Upgrading elements should cause no DOM mutations'); + } + } + let i = 0; - for (const {args, html, check} of expectations) { + for (const {args, html, setup, check} of expectations) { if (i === 0) { hydrate(testRender(...args), container); // Hydration should cause no DOM mutations, because it does not // actually update the DOM - it just recreates data structures - assert.isEmpty(getMutations(), 'Hydration should cause no DOM mutations'); + if (!expectMutationsDuringHydration) { + assert.isEmpty(getMutations(), 'Hydration should cause no DOM mutations'); + } clearMutations(); + } + + // Custom setup callback + if (setup !== undefined) { + const ret = setup(assert, container); + // Avoid introducing microtasks unless setup function was async + if (ret && (ret as any).then) { + await ret; + } + } + // After hydration, render() will be operable. + render(testRender(...args), container); - // After hydration, render() will be operable. - render(testRender(...args), container); + if (i === 0) { // The first render should also cause no mutations, since it's using // the same data as the server. if (!expectMutationsOnFirstRender) { assert.isEmpty(getMutations(), 'First render should cause no DOM mutations'); } - } else { - render(testRender(...args), container); } - // Check the markup - assert.lightDom.equal(container, html); + // Custom check before HTML assertion, so it can await el.updateComplete + if (check !== undefined) { + const ret = check(assert, container); + // Avoid introducing microtasks unless check function was async + if (ret && (ret as any).then) { + await ret; + } + } // Check that stable nodes didn't change const checkNodes = stableSelectors.map( (selector) => container.querySelector(selector)); assert.deepEqual(stableNodes, checkNodes); - // Custom check - if (check !== undefined) { - check(assert, container); - } + // Check the markup + assertHTML(container, html); + i++; } }); diff --git a/src/test/integration/server/server.ts b/src/test/integration/server/server.ts index 0b5b4c4..3c1d521 100644 --- a/src/test/integration/server/server.ts +++ b/src/test/integration/server/server.ts @@ -38,8 +38,12 @@ export const startServer = async (port = 9090) => { const {namespace} = await importModule(`../tests/${suiteName}-ssr.js`, import.meta.url, window); const module = namespace as typeof testModule; - const test = module.tests[testName] as SSRTest; + const testDescOrFn = module.tests[testName] as SSRTest; + const test = (typeof testDescOrFn === 'function') ? testDescOrFn() : testDescOrFn; const {render} = module; + if (test.registerElements) { + await test.registerElements(); + } // For debugging: if (false) { const result = render(test.render(...test.expectations[0].args)); diff --git a/src/test/integration/test.ts b/src/test/integration/test.ts index 8c24d8b..2d1c57c 100644 --- a/src/test/integration/test.ts +++ b/src/test/integration/test.ts @@ -24,7 +24,7 @@ const options = [ name: 'debug', type: Boolean, alias: 'd', - description: 'Opens a Chrome window and runs karma in debug mode', + description: 'Runs karma in debug mode without opening a webdriver-controlled browser', defaultValue: false }, ]; @@ -62,8 +62,9 @@ const config: karma.ConfigOptions = deepmerge(createDefaultConfig({}), { } as any); if (args.debug) { - config.browsers = ['Chrome']; + config.browsers = []; config.singleRun = false; + (config as any).client.mocha.timeout = 100000000; } (async () => { diff --git a/src/test/integration/tests/basic.ts b/src/test/integration/tests/basic.ts index 1019a2b..94b0eb0 100644 --- a/src/test/integration/tests/basic.ts +++ b/src/test/integration/tests/basic.ts @@ -12,19 +12,27 @@ * http://polymer.github.io/PATENTS.txt */ -import {html, noChange, nothing} from 'lit-html'; +import {html, noChange, nothing, directive, Part, TemplateResult} from 'lit-html'; import {repeat} from 'lit-html/directives/repeat.js'; +import {guard} from 'lit-html/directives/guard.js'; +import {cache} from 'lit-html/directives/cache.js'; +import {classMap} from 'lit-html/directives/class-map.js'; +import {styleMap} from 'lit-html/directives/style-map.js'; +import {until} from 'lit-html/directives/until.js'; +import {asyncAppend} from 'lit-html/directives/async-append.js'; +import {asyncReplace} from 'lit-html/directives/async-replace.js'; +import {TestAsyncIterable} from 'lit-html/test/lib/test-async-iterable.js'; +import {ifDefined} from 'lit-html/directives/if-defined.js'; +import {live} from 'lit-html/directives/live.js'; + +import { LitElement, property } from 'lit-element'; +import {renderLight} from 'lit-element/lib/render-light.js'; import { SSRTest } from './ssr-test'; const filterNodes = (nodes: ArrayLike, nodeType: number) => Array.from(nodes).filter(n => n.nodeType === nodeType); -const testSymbol = Symbol(); -const testObject = {}; -const testArray = [1,2,3]; -const testFunction = () => 'test function'; - export const tests: {[name: string] : SSRTest} = { // TODO: add suites (for now, delineating with comments) @@ -422,44 +430,269 @@ export const tests: {[name: string] : SSRTest} = { stableSelectors: ['ol', 'li'], }, - 'NodePart accepts repeat with strings': { - // TODO: repeat directive not currently implemented - skip: true, + 'NodePart accepts directive: repeat (with strings)': { render(words: string[]) { return html`${repeat(words, (word, i) => `(${i} ${word})`)}`; }, expectations: [ { args: [['foo', 'bar', 'qux']], - html: '(0 foo)\n(1 bar)\n(2 qux)' + html: '(0 foo)\n(1 bar)\n(2 qux)\n' }, { args: [['A', 'B', 'C']], - html: '(0 A)(1 B)(2 C)' + html: '(0 A)\n(1 B)\n(2 C)\n' } ], stableSelectors: [], }, - 'NodePart accepts repeat with templates': { - // TODO: repeat directive not currently implemented - skip: true, + 'NodePart accepts directive: repeat (with templates)': { render(words: string[]) { return html`${repeat(words, (word, i) => html`

${i}) ${word}

`)}`; }, expectations: [ { args: [['foo', 'bar', 'qux']], - html: '

0) foo

1) bar

2) qux

' + html: '

\n 0\n )\n foo\n

\n

\n 1\n )\n bar\n

\n

\n 2\n )\n qux\n

\n' }, { args: [['A', 'B', 'C']], - html: '

0) A

1) B

2) C

' + html: '

\n 0\n )\n A\n

\n

\n 1\n )\n B\n

\n

\n 2\n )\n C\n

\n' } ], stableSelectors: ['p'], }, + 'NodePart accepts directive: cache': { + render(bool: boolean) { + return html`${cache(bool ? html`

true

` : html`false` )}`; + }, + expectations: [ + { + args: [true], + html: '

true

' + }, + { + args: [false], + html: 'false' + }, + { + args: [true], + html: '

true

' + } + ], + stableSelectors: [], + }, + + 'NodePart accepts directive: guard': () => { + let guardedCallCount = 0; + const guardedTemplate = (bool: boolean) => { + guardedCallCount++; + return html`value is ${bool ? true : false}`; + } + return { + render(bool: boolean) { + return html`
${guard([bool], () => guardedTemplate(bool))}
` + }, + expectations: [ + { + args: [true], + html: '
value is\n true
', + check(assert: Chai.Assert) { + assert.equal(guardedCallCount, 1); + } + }, + { + args: [true], + html: '
value is\n true
', + check(assert: Chai.Assert) { + assert.equal(guardedCallCount, 1); + } + }, + { + args: [false], + html: '
value is\n false
', + check(assert: Chai.Assert) { + assert.equal(guardedCallCount, 2); + } + } + ], + stableSelectors: ['div'], + }; + }, + + 'NodePart accepts directive: until (primitive)': { + render(...args) { + return html`
${until(...args)}
` + }, + expectations: [ + { + args: ['foo'], + html: '
foo
', + }, + { + args: ['bar'], + html: '
bar
', + }, + ], + stableSelectors: ['div'], + }, + + 'NodePart accepts directive: until (promise, primitive)': () => { + let resolve: (v: string) => void; + const promise = new Promise(r => resolve = r); + return { + render(...args) { + return html`
${until(...args)}
` + }, + expectations: [ + { + args: [promise, 'foo'], + html: '
foo
', + }, + { + async setup() { + resolve('promise'); + await promise; + }, + args: [promise, 'foo'], + html: '
promise
', + }, + ], + stableSelectors: ['div'], + }; + }, + + 'NodePart accepts directive: until (promise, promise)': () => { + let resolve1: (v: string) => void; + let resolve2: (v: string) => void; + const promise1 = new Promise(r => resolve1 = r); + const promise2 = new Promise(r => resolve2 = r); + return { + render(...args) { + return html`
${until(...args)}
` + }, + expectations: [ + { + args: [promise2, promise1], + html: '
', + }, + { + async setup() { + resolve1('promise1'); + await promise1; + }, + args: [promise2, promise1], + html: '
promise1
', + }, + { + async setup() { + resolve2('promise2'); + await promise2; + }, + args: [promise2, promise1], + html: '
promise2
', + }, + ], + stableSelectors: ['div'], + }; + }, + + 'NodePart accepts directive: asyncAppend': () => { + const iterable = new TestAsyncIterable(); + return { + render(iterable) { + return html`
${asyncAppend(iterable)}
` + }, + expectations: [ + { + args: [iterable], + html: '
', + }, + { + async setup() { + await iterable.push('a'); + }, + args: [iterable], + html: '
a
', + }, + { + async setup() { + await iterable.push('b'); + }, + args: [iterable], + html: '
\n a\n b\n
', + }, + ], + stableSelectors: ['div'], + }; + }, + + 'NodePart accepts directive: asyncReplace': () => { + const iterable = new TestAsyncIterable(); + return { + render(iterable) { + return html`
${asyncReplace(iterable)}
` + }, + expectations: [ + { + args: [iterable], + html: '
', + }, + { + async setup() { + await iterable.push('a'); + }, + args: [iterable], + html: '
a
', + }, + { + async setup() { + await iterable.push('b'); + }, + args: [iterable], + html: '
b
', + }, + ], + stableSelectors: ['div'], + }; + }, + + 'NodePart accepts directive: ifDefined (undefined)': { + render(v) { + return html`
${ifDefined(v)}
` + }, + expectations: [ + { + args: [undefined], + html: '
', + }, + { + args: ['foo'], + html: '
foo
', + }, + ], + stableSelectors: ['div'], + }, + + 'NodePart accepts directive: ifDefined (defined)': { + render(v) { + return html`
${ifDefined(v)}
` + }, + expectations: [ + { + args: ['foo'], + html: '
foo
', + }, + { + args: [undefined], + html: '
', + }, + ], + stableSelectors: ['div'], + }, + /****************************************************** * AttributePart tests ******************************************************/ @@ -533,9 +766,6 @@ export const tests: {[name: string] : SSRTest} = { }, 'AttributePart accepts noChange': { - // TODO: Test currently fails: `noChange` causes class="[object Object]" - // to be rendered; to be investigated - skip: true, render(x: any) { return html`
`; }, @@ -609,7 +839,6 @@ export const tests: {[name: string] : SSRTest} = { 'AttributePart accepts an array': { // TODO: Test currently fails: the default array.toString is being used // during SSR, causing commas between values to be rendered. To be fixed. - skip: true, render(x: any) { return html`
`; }, @@ -624,6 +853,253 @@ export const tests: {[name: string] : SSRTest} = { } ], stableSelectors: ['div'], + // Setting an iterable always results in setAttribute being called + expectMutationsOnFirstRender: true, + }, + + 'AttributePart accepts directive: classMap': { + render(map: any) { + return html`
`; + }, + expectations: [ + { + args: [{foo: true, bar: false, baz: true}], + html: '
' + }, + { + args: [{foo: false, bar: true, baz: true, zug: true}], + html: '
' + } + ], + stableSelectors: ['div'], + }, + + 'AttributePart accepts directive: classMap (with statics)': { + render(map: any) { + return html`
`; + }, + expectations: [ + { + args: [{foo: true, bar: false, baz: true}], + html: '
' + }, + { + args: [{foo: false, bar: true, baz: true, zug: true}], + html: '
' + } + ], + stableSelectors: ['div'], + }, + + 'AttributePart accepts directive: styleMap': { + render(map: any) { + return html`
`; + }, + expectations: [ + { + args: [{background: 'red', paddingTop: '10px', '--my-prop': 'green'}], + html: '
' + }, + { + args: [{paddingTop: '20px', '--my-prop': 'gray', backgroundColor: 'white'}], + html: '
' + } + ], + // styleMap does not dirty check individual properties before setting, + // which causes an attribute mutation even if the text has not changed + expectMutationsOnFirstRender: true, + stableSelectors: ['div'], + }, + + 'AttributePart accepts directive: styleMap (with statics)': { + render(map: any) { + return html`
`; + }, + expectations: [ + { + args: [{background: 'green'}], + html: '
' + }, + { + args: [{paddingTop: '20px'}], + html: '
' + } + ], + // styleMap does not dirty check individual properties before setting, + // which causes an attribute mutation even if the text has not changed + expectMutationsOnFirstRender: true, + stableSelectors: ['div'], + }, + + 'AttributePart accepts directive: guard': () => { + let guardedCallCount = 0; + const guardedValue = (bool: boolean) => { + guardedCallCount++; + return bool ? 'true' : 'false'; + } + return { + render(bool: boolean) { + return html`
` + }, + expectations: [ + { + args: [true], + html: '
', + check(assert: Chai.Assert) { + assert.equal(guardedCallCount, 1); + } + }, + { + args: [true], + html: '
', + check(assert: Chai.Assert) { + assert.equal(guardedCallCount, 1); + } + }, + { + args: [false], + html: '
', + check(assert: Chai.Assert) { + assert.equal(guardedCallCount, 2); + } + } + ], + stableSelectors: ['div'], + }; + }, + + 'AttributePart accepts directive: until (primitive)': { + render(...args) { + return html`
` + }, + expectations: [ + { + args: ['foo'], + html: '
', + }, + { + args: ['bar'], + html: '
', + }, + ], + stableSelectors: ['div'], + // until always calls setValue each render, with no dirty-check of previous + // value + expectMutationsOnFirstRender: true, + }, + + 'AttributePart accepts directive: until (promise, primitive)': () => { + let resolve: (v: string) => void; + const promise = new Promise(r => resolve = r); + return { + render(...args) { + return html`
` + }, + expectations: [ + { + args: [promise, 'foo'], + html: '
', + }, + { + async setup() { + resolve('promise'); + await promise; + }, + args: [promise, 'foo'], + html: '
', + }, + ], + stableSelectors: ['div'], + // until always calls setValue each render, with no dirty-check of previous + // value + expectMutationsOnFirstRender: true, + }; + }, + + 'AttributePart accepts directive: until (promise, promise)': () => { + let resolve1: (v: string) => void; + let resolve2: (v: string) => void; + const promise1 = new Promise(r => resolve1 = r); + const promise2 = new Promise(r => resolve2 = r); + return { + render(...args) { + return html`
` + }, + expectations: [ + { + args: [promise2, promise1], + html: '
', + }, + { + async setup() { + resolve1('promise1'); + await promise1; + }, + args: [promise2, promise1], + html: '
', + }, + { + async setup() { + resolve2('promise2'); + await promise2; + }, + args: [promise2, promise1], + html: '
', + }, + ], + stableSelectors: ['div'], + } + }, + + 'AttributePart accepts directive: ifDefined (undefined)': { + render(v) { + return html`
` + }, + expectations: [ + { + args: [undefined], + html: '
', + }, + { + args: ['foo'], + html: '
', + }, + ], + stableSelectors: ['div'], + }, + + 'AttributePart accepts directive: ifDefined (defined)': { + render(v) { + return html`
` + }, + expectations: [ + { + args: ['foo'], + html: '
', + }, + { + args: [undefined], + html: '
', + }, + ], + stableSelectors: ['div'], + }, + + 'AttributePart accepts directive: live': { + render(v: string) { + return html`
` + }, + expectations: [ + { + args: ['foo'], + html: '
', + }, + { + args: ['bar'], + html: '
', + }, + ], + stableSelectors: ['div'], }, 'multiple AttributeParts on same node': { @@ -704,6 +1180,33 @@ export const tests: {[name: string] : SSRTest} = { stableSelectors: ['div'], }, + 'PropertyPart accepts a string (reflected)': { + render(x: any) { + return html`
`; + }, + expectations: [ + { + args: ['foo'], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + assert.strictEqual(dom.querySelector('div')!.className, 'foo'); + } + }, + { + args: ['foo2'], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + assert.strictEqual(dom.querySelector('div')!.className, 'foo2'); + } + } + ], + stableSelectors: ['div'], + // We set properties during hydration, and natively-reflecting properties + // will trigger a "mutation" even when set to the same value that was + // rendered to its attribute + expectMutationsDuringHydration: true, + }, + 'PropertyPart accepts a number': { render(x: any) { return html`
`; @@ -727,88 +1230,97 @@ export const tests: {[name: string] : SSRTest} = { stableSelectors: ['div'], }, - 'PropertyPart accepts a boolean': { + 'PropertyPart accepts a number (reflected)': { render(x: any) { - return html`
`; + return html`
`; }, expectations: [ { - args: [false], - html: '
', + args: [1], + html: '
', check(assert: Chai.Assert, dom: HTMLElement) { - assert.strictEqual((dom.querySelector('div') as any).foo, false); + // Note className coerces to string + assert.strictEqual(dom.querySelector('div')!.className, '1'); } }, { - args: [true], - html: '
', + args: [2], + html: '
', check(assert: Chai.Assert, dom: HTMLElement) { - assert.strictEqual((dom.querySelector('div') as any).foo, true); + // Note className coerces to string + assert.strictEqual(dom.querySelector('div')!.className, '2'); } } ], stableSelectors: ['div'], + // We set properties during hydration, and natively-reflecting properties + // will trigger a "mutation" even when set to the same value that was + // rendered to its attribute + expectMutationsDuringHydration: true, }, - 'PropertyPart accepts undefined': { + 'PropertyPart accepts a boolean': { render(x: any) { return html`
`; }, expectations: [ { - args: [undefined], + args: [false], html: '
', check(assert: Chai.Assert, dom: HTMLElement) { - assert.strictEqual((dom.querySelector('div') as any).foo, undefined); + assert.strictEqual((dom.querySelector('div') as any).foo, false); } }, { - args: [1], + args: [true], html: '
', check(assert: Chai.Assert, dom: HTMLElement) { - assert.strictEqual((dom.querySelector('div') as any).foo, 1); + assert.strictEqual((dom.querySelector('div') as any).foo, true); } } ], stableSelectors: ['div'], }, - 'PropertyPart accepts null': { + 'PropertyPart accepts a boolean (reflected)': { render(x: any) { - return html`
`; + return html`
`; }, expectations: [ { - args: [null], - html: '
', + args: [false], + html: '
', check(assert: Chai.Assert, dom: HTMLElement) { - assert.strictEqual((dom.querySelector('div') as any).foo, null); + // Note className coerces to string + assert.strictEqual(dom.querySelector('div')!.className, 'false'); } }, { - args: [1], - html: '
', + args: [true], + html: '
', check(assert: Chai.Assert, dom: HTMLElement) { - assert.strictEqual((dom.querySelector('div') as any).foo, 1); + // Note className coerces to string + assert.strictEqual(dom.querySelector('div')!.className, 'true'); } } ], stableSelectors: ['div'], + // We set properties during hydration, and natively-reflecting properties + // will trigger a "mutation" even when set to the same value that was + // rendered to its attribute + expectMutationsDuringHydration: true, }, - 'PropertyPart accepts noChange': { - // TODO: Test currently fails: SSR does not currently accept noChange in - // property position. To fix. - skip: true, + 'PropertyPart accepts undefined': { render(x: any) { return html`
`; }, expectations: [ { - args: [noChange], + args: [undefined], html: '
', check(assert: Chai.Assert, dom: HTMLElement) { - assert.notProperty(dom.querySelector('div'), 'foo'); + assert.strictEqual((dom.querySelector('div') as any).foo, undefined); } }, { @@ -822,41 +1334,45 @@ export const tests: {[name: string] : SSRTest} = { stableSelectors: ['div'], }, - 'PropertyPart accepts nothing': { - // TODO: the current client-side does nothing special with `nothing`, just - // passes it on to the property; is that what we want? + 'PropertyPart accepts undefined (reflected)': { render(x: any) { - return html`
`; + return html`
`; }, expectations: [ { - args: [nothing], - html: '
', + args: [undefined], + html: '
', check(assert: Chai.Assert, dom: HTMLElement) { - assert.strictEqual((dom.querySelector('div') as any).foo, nothing); + // Note className coerces to string + assert.strictEqual(dom.querySelector('div')!.className, 'undefined'); } }, { args: [1], - html: '
', + html: '
', check(assert: Chai.Assert, dom: HTMLElement) { - assert.strictEqual((dom.querySelector('div') as any).foo, 1); + // Note className coerces to string + assert.strictEqual(dom.querySelector('div')!.className, '1'); } } ], stableSelectors: ['div'], + // We set properties during hydration, and natively-reflecting properties + // will trigger a "mutation" even when set to the same value that was + // rendered to its attribute + expectMutationsDuringHydration: true, }, - 'PropertyPart accepts a symbol': { + 'PropertyPart accepts null': { render(x: any) { return html`
`; }, expectations: [ { - args: [testSymbol], + args: [null], html: '
', check(assert: Chai.Assert, dom: HTMLElement) { - assert.strictEqual((dom.querySelector('div') as any).foo, testSymbol); + assert.strictEqual((dom.querySelector('div') as any).foo, null); } }, { @@ -870,39 +1386,48 @@ export const tests: {[name: string] : SSRTest} = { stableSelectors: ['div'], }, - 'PropertyPart accepts an object': { + 'PropertyPart accepts null (reflected)': { render(x: any) { - return html`
`; + return html`
`; }, expectations: [ { - args: [testObject], - html: '
', + args: [null], + html: '
', check(assert: Chai.Assert, dom: HTMLElement) { - assert.strictEqual((dom.querySelector('div') as any).foo, testObject); + // Note className coerces to string + assert.strictEqual(dom.querySelector('div')!.className, 'null'); } }, { args: [1], - html: '
', + html: '
', check(assert: Chai.Assert, dom: HTMLElement) { - assert.strictEqual((dom.querySelector('div') as any).foo, 1); + // Note className coerces to string + assert.strictEqual(dom.querySelector('div')!.className, '1'); } } ], stableSelectors: ['div'], + // We set properties during hydration, and natively-reflecting properties + // will trigger a "mutation" even when set to the same value that was + // rendered to its attribute + expectMutationsDuringHydration: true, }, - 'PropertyPart accepts an array': { + 'PropertyPart accepts noChange': { render(x: any) { return html`
`; }, expectations: [ { - args: [testArray], + args: [noChange], html: '
', check(assert: Chai.Assert, dom: HTMLElement) { - assert.strictEqual((dom.querySelector('div') as any).foo, testArray); + // Ideally this would be `notProperty`, but this is actually how + // the client-side works right now, because the committer starts off + // as dirty + assert.strictEqual((dom.querySelector('div') as any).foo, undefined); } }, { @@ -916,30 +1441,803 @@ export const tests: {[name: string] : SSRTest} = { stableSelectors: ['div'], }, - 'PropertyPart accepts a function': { + 'PropertyPart accepts noChange (reflected)': { + // TODO: Right now, SSR just reflects the raw value noChange, so it gets + // '[object Object]' in the HTML, but when hydration runes, the committer + // sets 'undefined' (which gets reflected), so there's no way to write a + // correct test; we should either fix the client-side to not set + // `undefined`, or else just serialize 'undefined' for noChange on the server + skip: true, render(x: any) { - return html`
`; + return html`
`; }, expectations: [ { - args: [testFunction], - html: '
', + args: [noChange], + html: '
', check(assert: Chai.Assert, dom: HTMLElement) { - assert.strictEqual((dom.querySelector('div') as any).foo, testFunction); + // Ideally this would be `notProperty`, but this is actually how + // the client-side works right now, because the committer starts off + // as dirty + assert.strictEqual(dom.querySelector('div')!.className, undefined); } }, { args: [1], - html: '
', + html: '
', check(assert: Chai.Assert, dom: HTMLElement) { - assert.strictEqual((dom.querySelector('div') as any).foo, 1); + // Note className coerces to string + assert.strictEqual(dom.querySelector('div')!.className, '1'); } } ], stableSelectors: ['div'], + // We set properties during hydration, and natively-reflecting properties + // will trigger a "mutation" even when set to the same value that was + // rendered to its attribute + expectMutationsDuringHydration: true, }, - 'multiple PropertyParts on same node': { + 'PropertyPart accepts nothing': { + // TODO: the current client-side does nothing special with `nothing`, just + // passes it on to the property; is that what we want? + render(x: any) { + return html`
`; + }, + expectations: [ + { + args: [nothing], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + assert.strictEqual((dom.querySelector('div') as any).foo, nothing); + } + }, + { + args: [1], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + assert.strictEqual((dom.querySelector('div') as any).foo, 1); + } + } + ], + stableSelectors: ['div'], + }, + + 'PropertyPart accepts nothing (reflected)': { + // TODO: the current client-side does nothing special with `nothing`, just + // passes it on to the property; is that what we want? + render(x: any) { + return html`
`; + }, + expectations: [ + { + args: [nothing], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + // Note className coerces to string + assert.strictEqual(dom.querySelector('div')!.className, '[object Object]'); + } + }, + { + args: [1], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + // Note className coerces to string + assert.strictEqual(dom.querySelector('div')!.className, '1'); + } + } + ], + stableSelectors: ['div'], + // We set properties during hydration, and natively-reflecting properties + // will trigger a "mutation" even when set to the same value that was + // rendered to its attribute + expectMutationsDuringHydration: true, + // Objects don't dirty check, so we get another mutation during first render + expectMutationsOnFirstRender: true, + }, + + 'PropertyPart accepts a symbol': () => { + const testSymbol = Symbol(); + return { + render(x: any) { + return html`
`; + }, + expectations: [ + { + args: [testSymbol], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + assert.strictEqual((dom.querySelector('div') as any).foo, testSymbol); + } + }, + { + args: [1], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + assert.strictEqual((dom.querySelector('div') as any).foo, 1); + } + } + ], + stableSelectors: ['div'], + }; + }, + + 'PropertyPart accepts a symbol (reflected)': () => { + const testSymbol = Symbol('testSymbol'); + return { + // Symbols can't be set to string-coercing properties like className & id + skip: true, + render(x: any) { + return html`
`; + }, + expectations: [ + { + args: [testSymbol], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + // Note className coerces to string + assert.strictEqual(dom.querySelector('div')!.className, 'Symbol(testSymbol)'); + } + }, + { + args: [1], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + // Note className coerces to string + assert.strictEqual(dom.querySelector('div')!.className, '1'); + } + } + ], + stableSelectors: ['div'], + // We set properties during hydration, and natively-reflecting properties + // will trigger a "mutation" even when set to the same value that was + // rendered to its attribute + expectMutationsDuringHydration: true, + }; + }, + + 'PropertyPart accepts an object': () => { + const testObject = {}; + return { + render(x: any) { + return html`
`; + }, + expectations: [ + { + args: [testObject], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + assert.strictEqual((dom.querySelector('div') as any).foo, testObject); + } + }, + { + args: [1], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + assert.strictEqual((dom.querySelector('div') as any).foo, 1); + } + } + ], + stableSelectors: ['div'], + }; + }, + + 'PropertyPart accepts an object (reflected)': () => { + const testObject = {}; + return { + render(x: any) { + return html`
`; + }, + expectations: [ + { + args: [testObject], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + // Note className coerces to string + assert.strictEqual(dom.querySelector('div')!.className, '[object Object]'); + } + }, + { + args: [1], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + // Note className coerces to string + assert.strictEqual(dom.querySelector('div')!.className, '1'); + } + } + ], + stableSelectors: ['div'], + // We set properties during hydration, and natively-reflecting properties + // will trigger a "mutation" even when set to the same value that was + // rendered to its attribute + expectMutationsDuringHydration: true, + // Objects don't dirty check, so we get another mutation during first render + expectMutationsOnFirstRender: true, + }; + }, + + 'PropertyPart accepts an array': () => { + const testArray = [1,2,3]; + return { + render(x: any) { + return html`
`; + }, + expectations: [ + { + args: [testArray], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + assert.strictEqual((dom.querySelector('div') as any).foo, testArray); + } + }, + { + args: [1], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + assert.strictEqual((dom.querySelector('div') as any).foo, 1); + } + } + ], + stableSelectors: ['div'], + }; + }, + + 'PropertyPart accepts an array (reflected)': () => { + const testArray = [1,2,3]; + return { + render(x: any) { + return html`
`; + }, + expectations: [ + { + args: [testArray], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + // Note className coerces to string + assert.strictEqual(dom.querySelector('div')!.className, '1,2,3'); + } + }, + { + args: [1], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + // Note className coerces to string + assert.strictEqual(dom.querySelector('div')!.className, '1'); + } + } + ], + stableSelectors: ['div'], + // We set properties during hydration, and natively-reflecting properties + // will trigger a "mutation" even when set to the same value that was + // rendered to its attribute + expectMutationsDuringHydration: true, + // Arrays don't dirty check, so we get another mutation during first render + expectMutationsOnFirstRender: true, + }; + }, + + 'PropertyPart accepts a function': () => { + const testFunction = () => 'test function'; + return { + render(x: any) { + return html`
`; + }, + expectations: [ + { + args: [testFunction], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + assert.strictEqual((dom.querySelector('div') as any).foo, testFunction); + } + }, + { + args: [1], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + assert.strictEqual((dom.querySelector('div') as any).foo, 1); + } + } + ], + stableSelectors: ['div'], + }; + }, + + 'PropertyPart accepts a function (reflected)': () => { + const testFunction = () => 'test function'; + return { + render(x: any) { + return html`
`; + }, + expectations: [ + { + args: [testFunction], + html: `
`, + check(assert: Chai.Assert, dom: HTMLElement) { + // Note className coerces to string + assert.strictEqual(dom.querySelector('div')!.className, `() => 'test function'`); + } + }, + { + args: [1], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + // Note className coerces to string + assert.strictEqual(dom.querySelector('div')!.className, '1'); + } + } + ], + stableSelectors: ['div'], + // We set properties during hydration, and natively-reflecting properties + // will trigger a "mutation" even when set to the same value that was + // rendered to its attribute + expectMutationsDuringHydration: true, + // Arrays don't dirty check, so we get another mutation during first render + expectMutationsOnFirstRender: true, + }; + }, + + 'PropertyPart accepts directive: guard': () => { + let guardedCallCount = 0; + const guardedValue = (bool: boolean) => { + guardedCallCount++; + return bool; + } + return { + render(bool: boolean) { + return html`
` + }, + expectations: [ + { + args: [true], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + assert.equal(guardedCallCount, 1); + assert.strictEqual((dom.querySelector('div') as any).prop, true); + } + }, + { + args: [true], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + assert.equal(guardedCallCount, 1); + assert.strictEqual((dom.querySelector('div') as any).prop, true); + } + }, + { + args: [false], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + assert.equal(guardedCallCount, 2); + assert.strictEqual((dom.querySelector('div') as any).prop, false); + } + } + ], + stableSelectors: ['div'], + }; + }, + + 'PropertyPart accepts directive: guard (reflected)': () => { + let guardedCallCount = 0; + const guardedValue = (v: string) => { + guardedCallCount++; + return v; + } + return { + render(v: string) { + return html`
` + }, + expectations: [ + { + args: ['foo'], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + assert.equal(guardedCallCount, 1); + // Note className coerces to string + assert.strictEqual(dom.querySelector('div')!.className, 'foo'); + } + }, + { + args: ['foo'], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + assert.equal(guardedCallCount, 1); + // Note className coerces to string + assert.strictEqual(dom.querySelector('div')!.className, 'foo'); + } + }, + { + args: ['bar'], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + assert.equal(guardedCallCount, 2); + // Note className coerces to string + assert.strictEqual(dom.querySelector('div')!.className, 'bar'); + } + } + ], + stableSelectors: ['div'], + // We set properties during hydration, and natively-reflecting properties + // will trigger a "mutation" even when set to the same value that was + // rendered to its attribute + expectMutationsDuringHydration: true, + }; + }, + + 'PropertyPart accepts directive: until (primitive)': { + render(...args) { + return html`
` + }, + expectations: [ + { + args: ['foo'], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + assert.strictEqual((dom.querySelector('div') as any).prop, 'foo'); + } + }, + { + args: ['bar'], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + assert.strictEqual((dom.querySelector('div') as any).prop, 'bar'); + } + }, + ], + stableSelectors: ['div'], + // until always calls setValue each render, with no dirty-check of previous + // value + expectMutationsOnFirstRender: true, + }, + + 'PropertyPart accepts directive: until (primitive) (reflected)': { + render(...args) { + return html`
` + }, + expectations: [ + { + args: ['foo'], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + // Note className coerces to string + assert.strictEqual(dom.querySelector('div')!.className, 'foo'); + } + }, + { + args: ['bar'], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + // Note className coerces to string + assert.strictEqual(dom.querySelector('div')!.className, 'bar'); + } + }, + ], + stableSelectors: ['div'], + // We set properties during hydration, and natively-reflecting properties + // will trigger a "mutation" even when set to the same value that was + // rendered to its attribute + expectMutationsDuringHydration: true, + // until always calls setValue each render, with no dirty-check of previous + // value + expectMutationsOnFirstRender: true, + }, + + 'PropertyPart accepts directive: until (promise, primitive)': () => { + let resolve: (v: string) => void; + const promise = new Promise(r => resolve = r); + return { + render(...args) { + return html`
` + }, + expectations: [ + { + args: [promise, 'foo'], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + assert.strictEqual((dom.querySelector('div') as any).prop, 'foo'); + } + }, + { + async setup() { + resolve('promise'); + await promise; + }, + args: [promise, 'foo'], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + assert.strictEqual((dom.querySelector('div') as any).prop, 'promise'); + } + }, + ], + stableSelectors: ['div'], + // until always calls setValue each render, with no dirty-check of previous + // value + expectMutationsOnFirstRender: true, + }; + }, + + 'PropertyPart accepts directive: until (promise, primitive) (reflected)': () => { + let resolve: (v: string) => void; + const promise = new Promise(r => resolve = r); + return { + render(...args) { + return html`
` + }, + expectations: [ + { + args: [promise, 'foo'], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + // Note className coerces to string + assert.strictEqual(dom.querySelector('div')!.className, 'foo'); + } + }, + { + async setup() { + resolve('promise'); + await promise; + }, + args: [promise, 'foo'], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + // Note className coerces to string + assert.strictEqual(dom.querySelector('div')!.className, 'promise'); + } + }, + ], + stableSelectors: ['div'], + // We set properties during hydration, and natively-reflecting properties + // will trigger a "mutation" even when set to the same value that was + // rendered to its attribute + expectMutationsDuringHydration: true, + // until always calls setValue each render, with no dirty-check of previous + // value + expectMutationsOnFirstRender: true, + }; + }, + + 'PropertyPart accepts directive: until (promise, promise)': () => { + let resolve1: (v: string) => void; + let resolve2: (v: string) => void; + const promise1 = new Promise(r => resolve1 = r); + const promise2 = new Promise(r => resolve2 = r); + return { + render(...args) { + return html`
` + }, + expectations: [ + { + args: [promise2, promise1], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + assert.notProperty((dom.querySelector('div') as any), 'prop'); + } + }, + { + async setup() { + resolve1('promise1'); + await promise1; + }, + args: [promise2, promise1], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + assert.strictEqual((dom.querySelector('div') as any).prop, 'promise1'); + } + }, + { + async setup() { + resolve2('promise2'); + await promise2; + }, + args: [promise2, promise1], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + assert.strictEqual((dom.querySelector('div') as any).prop, 'promise2'); + } + }, + ], + stableSelectors: ['div'], + } + }, + + 'PropertyPart accepts directive: until (promise, promise) (reflected)': () => { + let resolve1: (v: string) => void; + let resolve2: (v: string) => void; + const promise1 = new Promise(r => resolve1 = r); + const promise2 = new Promise(r => resolve2 = r); + return { + render(...args) { + return html`
` + }, + expectations: [ + { + args: [promise2, promise1], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + // Note className coerces to string + assert.strictEqual(dom.querySelector('div')!.className, ''); + } + }, + { + async setup() { + resolve1('promise1'); + await promise1; + }, + args: [promise2, promise1], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + // Note className coerces to string + assert.strictEqual(dom.querySelector('div')!.className, 'promise1'); + } + }, + { + async setup() { + resolve2('promise2'); + await promise2; + }, + args: [promise2, promise1], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + // Note className coerces to string + assert.strictEqual(dom.querySelector('div')!.className, 'promise2'); + } + }, + ], + stableSelectors: ['div'], + } + }, + + 'PropertyPart accepts directive: ifDefined (undefined)': { + render(v) { + return html`
` + }, + expectations: [ + { + args: [undefined], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + assert.notProperty((dom.querySelector('div') as any), 'prop'); + } + }, + { + args: ['foo'], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + assert.strictEqual((dom.querySelector('div') as any).prop, 'foo'); + } + }, + ], + stableSelectors: ['div'], + }, + + 'PropertyPart accepts directive: ifDefined (undefined) (reflected)': { + render(v) { + return html`
` + }, + expectations: [ + { + args: [undefined], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + // Note className coerces to string + assert.strictEqual(dom.querySelector('div')!.className, ''); + } + }, + { + args: ['foo'], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + // Note className coerces to string + assert.strictEqual(dom.querySelector('div')!.className, 'foo'); + } + }, + ], + stableSelectors: ['div'], + }, + + 'PropertyPart accepts directive: ifDefined (defined)': { + render(v) { + return html`
` + }, + expectations: [ + { + args: ['foo'], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + assert.strictEqual((dom.querySelector('div') as any).prop, 'foo'); + } + }, + { + args: [undefined], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + assert.strictEqual((dom.querySelector('div') as any).prop, undefined); + } + }, + ], + stableSelectors: ['div'], + }, + + 'PropertyPart accepts directive: ifDefined (defined) (reflected)': { + render(v) { + return html`
` + }, + expectations: [ + { + args: ['foo'], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + // Note className coerces to string + assert.strictEqual(dom.querySelector('div')!.className, 'foo'); + } + }, + { + args: [undefined], + // `ifDefined` is supposed to be a no-op for non-attribute parts, which + // means it sets `undefined` through, which sets it to the className + // property which is coerced to 'undefined' and reflected + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + // Note className coerces to string + assert.strictEqual(dom.querySelector('div')!.className, 'undefined'); + } + }, + ], + stableSelectors: ['div'], + // We set properties during hydration, and natively-reflecting properties + // will trigger a "mutation" even when set to the same value that was + // rendered to its attribute + expectMutationsDuringHydration: true, + }, + + 'PropertyPart accepts directive: live': { + render(v: string) { + return html`
` + }, + expectations: [ + { + args: ['foo'], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + assert.strictEqual((dom.querySelector('div') as any).prop, 'foo'); + } + }, + { + args: ['bar'], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + assert.strictEqual((dom.querySelector('div') as any).prop, 'bar'); + } + }, + ], + stableSelectors: ['div'], + }, + + 'PropertyPart accepts directive: live (reflected)': { + render(v: string) { + return html`
` + }, + expectations: [ + { + args: ['foo'], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + // Note className coerces to string + assert.strictEqual(dom.querySelector('div')!.className, 'foo'); + } + }, + { + args: ['bar'], + html: '
', + check(assert: Chai.Assert, dom: HTMLElement) { + // Note className coerces to string + assert.strictEqual(dom.querySelector('div')!.className, 'bar'); + } + }, + ], + stableSelectors: ['div'], + }, + + 'multiple PropertyParts on same node': { render(x: any, y: any) { return html`
`; }, @@ -1002,7 +2300,7 @@ export const tests: {[name: string] : SSRTest} = { check(assert: Chai.Assert, dom: HTMLElement) { const button = dom.querySelector('button')!; button.click(); - assert.strictEqual((button as any).__wasClicked, true); + assert.strictEqual((button as any).__wasClicked, true, 'not clicked during first render'); } }, { @@ -1011,13 +2309,238 @@ export const tests: {[name: string] : SSRTest} = { check(assert: Chai.Assert, dom: HTMLElement) { const button = dom.querySelector('button')!; button.click(); - assert.strictEqual((button as any).__wasClicked2, true); + assert.strictEqual((button as any).__wasClicked2, true, 'not clicked during second render'); } } ], stableSelectors: ['button'], }, + 'EventPart accepts directive: guard': () => { + const listener1 = (e: Event) => (e.target as any).__wasClicked1 = true; + const listener2 = (e: Event) => (e.target as any).__wasClicked2 = true; + let guardedCallCount = 0; + const guardedValue = (fn: (e: Event) => any) => { + guardedCallCount++; + return fn; + } + return { + render(fn: (e: Event) => any) { + return html`` + }, + expectations: [ + { + args: [listener1], + html: '', + check(assert: Chai.Assert, dom: HTMLElement) { + assert.equal(guardedCallCount, 1); + const button = dom.querySelector('button')!; + button.click(); + assert.strictEqual((button as any).__wasClicked1, true, 'not clicked during first render'); + } + }, + { + args: [listener1], + html: '', + check(assert: Chai.Assert, dom: HTMLElement) { + assert.equal(guardedCallCount, 1); + const button = dom.querySelector('button')!; + button.click(); + assert.strictEqual((button as any).__wasClicked1, true, 'not clicked during second render'); + } + }, + { + args: [listener2], + html: '', + check(assert: Chai.Assert, dom: HTMLElement) { + assert.equal(guardedCallCount, 2); + const button = dom.querySelector('button')!; + button.click(); + assert.strictEqual((button as any).__wasClicked2, true, 'not clicked during third render'); + } + } + ], + stableSelectors: ['button'], + }; + }, + + 'EventPart accepts directive: until (listener)': () => { + const listener1 = (e: Event) => (e.target as any).__wasClicked1 = true; + const listener2 = (e: Event) => (e.target as any).__wasClicked2 = true; + return { + render(...args) { + return html`` + }, + expectations: [ + { + args: [listener1], + html: '', + check(assert: Chai.Assert, dom: HTMLElement) { + const button = dom.querySelector('button')!; + button.click(); + assert.strictEqual((button as any).__wasClicked1, true, 'not clicked during first render'); + } + }, + { + args: [listener2], + html: '', + check(assert: Chai.Assert, dom: HTMLElement) { + const button = dom.querySelector('button')!; + button.click(); + assert.strictEqual((button as any).__wasClicked2, true, 'not clicked during second render'); + } + }, + ], + stableSelectors: ['button'], + }; + }, + + 'EventPart accepts directive: until (promise, listener)': () => { + const listener1 = (e: Event) => (e.target as any).__wasClicked1 = true; + const listener2 = (e: Event) => (e.target as any).__wasClicked2 = true; + let resolve: (v: (e: Event) => any) => void; + const promise = new Promise(r => resolve = r); + return { + render(...args) { + return html`` + }, + expectations: [ + { + args: [promise, listener1], + html: '', + check(assert: Chai.Assert, dom: HTMLElement) { + const button = dom.querySelector('button')!; + button.click(); + assert.strictEqual((button as any).__wasClicked1, true, 'not clicked during first render'); + } + }, + { + async setup() { + resolve(listener2); + await promise; + }, + args: [promise, listener1], + html: '', + check(assert: Chai.Assert, dom: HTMLElement) { + const button = dom.querySelector('button')!; + button.click(); + assert.strictEqual((button as any).__wasClicked2, true, 'not clicked during second render'); + } + }, + ], + stableSelectors: ['button'], + }; + }, + + 'EventPart accepts directive: until (promise, promise)': () => { + const listener1 = (e: Event) => (e.target as any).__wasClicked1 = true; + const listener2 = (e: Event) => (e.target as any).__wasClicked2 = true; + let resolve1: (v: (e: Event) => any) => void; + let resolve2: (v: (e: Event) => any) => void; + const promise1 = new Promise(r => resolve1 = r); + const promise2 = new Promise(r => resolve2 = r); + return { + render(...args) { + return html`` + }, + expectations: [ + { + args: [promise2, promise1], + html: '', + check(assert: Chai.Assert, dom: HTMLElement) { + assert.notProperty((dom.querySelector('button') as any), 'prop'); + const button = dom.querySelector('button')!; + button.click(); + assert.notProperty(button, '__wasClicked1', 'was clicked during first render'); + } + }, + { + async setup() { + resolve1(listener1); + await promise1; + }, + args: [promise2, promise1], + html: '', + check(assert: Chai.Assert, dom: HTMLElement) { + const button = dom.querySelector('button')!; + button.click(); + assert.strictEqual((button as any).__wasClicked1, true, 'not clicked during second render'); + } + }, + { + async setup() { + resolve2(listener2); + await promise2; + }, + args: [promise2, promise1], + html: '', + check(assert: Chai.Assert, dom: HTMLElement) { + const button = dom.querySelector('button')!; + button.click(); + assert.strictEqual((button as any).__wasClicked2, true, 'not clicked during third render'); + } + }, + ], + stableSelectors: ['button'], + } + }, + + 'EventPart accepts directive: ifDefined (undefined)': { + render(v) { + return html`` + }, + expectations: [ + { + args: [undefined], + html: '', + check(assert: Chai.Assert, dom: HTMLElement) { + assert.notProperty((dom.querySelector('button') as any), 'prop'); + const button = dom.querySelector('button')!; + button.click(); + assert.notProperty(button, '__wasClicked1', 'was clicked during first render'); + } + }, + { + args: [(e: Event) => (e.target as any).__wasClicked = true], + html: '', + check(assert: Chai.Assert, dom: HTMLElement) { + const button = dom.querySelector('button')!; + button.click(); + assert.strictEqual((button as any).__wasClicked, true, 'not clicked during second render'); + } + }, + ], + stableSelectors: ['button'], + }, + + 'EventPart accepts directive: ifDefined (defined)': { + render(v) { + return html`` + }, + expectations: [ + { + args: [(e: Event) => (e.target as any).__wasClicked = true], + html: '', + check(assert: Chai.Assert, dom: HTMLElement) { + const button = dom.querySelector('button')!; + button.click(); + assert.strictEqual((button as any).__wasClicked, true, 'not clicked during second render'); + } + }, + { + args: [undefined], + html: '', + check(assert: Chai.Assert, dom: HTMLElement) { + assert.notProperty((dom.querySelector('button') as any), 'prop'); + const button = dom.querySelector('button')!; + button.click(); + assert.notProperty(button, '__wasClicked1', 'was clicked during first render'); + } + }, + ], + stableSelectors: ['button'], + }, + /****************************************************** * BooleanAttributePart tests ******************************************************/ @@ -1162,8 +2685,6 @@ export const tests: {[name: string] : SSRTest} = { }, 'BooleanAttributePart, initially noChange': { - // TODO: Test currently fails: `noChange` causes attribute to be rendered - skip: true, render(hide: boolean) { return html`
`; }, @@ -1180,6 +2701,177 @@ export const tests: {[name: string] : SSRTest} = { stableSelectors: ['div'], }, + 'BooleanAttributePart accepts directive: guard': () => { + let guardedCallCount = 0; + const guardedValue = (bool: boolean) => { + guardedCallCount++; + return bool; + } + return { + render(bool: boolean) { + return html`
` + }, + expectations: [ + { + args: [true], + html: '', + check(assert: Chai.Assert) { + assert.equal(guardedCallCount, 1); + } + }, + { + args: [true], + html: '', + check(assert: Chai.Assert) { + assert.equal(guardedCallCount, 1); + } + }, + { + args: [false], + html: '
', + check(assert: Chai.Assert) { + assert.equal(guardedCallCount, 2); + } + } + ], + stableSelectors: ['div'], + }; + }, + + 'BooleanAttributePart accepts directive: until (primitive)': { + render(...args) { + return html`
` + }, + expectations: [ + { + args: [true], + html: '', + }, + { + args: [false], + html: '
', + }, + ], + stableSelectors: ['div'], + // until always calls setValue each render, with no dirty-check of previous + // value + expectMutationsOnFirstRender: true, + }, + + 'BooleanAttributePart accepts directive: until (promise, primitive)': () => { + let resolve: (v: boolean) => void; + const promise = new Promise(r => resolve = r); + return { + render(...args) { + return html`
` + }, + expectations: [ + { + args: [promise, true], + html: '', + }, + { + async setup() { + resolve(false); + await promise; + }, + args: [promise, true], + html: '
', + }, + ], + stableSelectors: ['div'], + // until always calls setValue each render, with no dirty-check of previous + // value + expectMutationsOnFirstRender: true, + }; + }, + + 'BooleanAttributePart accepts directive: until (promise, promise)': () => { + let resolve1: (v: boolean) => void; + let resolve2: (v: boolean) => void; + const promise1 = new Promise(r => resolve1 = r); + const promise2 = new Promise(r => resolve2 = r); + return { + render(...args) { + return html`
` + }, + expectations: [ + { + args: [promise2, promise1], + html: '
', + }, + { + async setup() { + resolve1(true); + await promise1; + }, + args: [promise2, promise1], + html: '', + }, + { + async setup() { + resolve2(false); + await promise2; + }, + args: [promise2, promise1], + html: '
', + }, + ], + stableSelectors: ['div'], + } + }, + + 'BooleanAttributePart accepts directive: ifDefined (undefined)': { + render(v) { + return html`
` + }, + expectations: [ + { + args: [undefined], + html: '
', + }, + { + args: ['foo'], + html: '', + }, + ], + stableSelectors: ['div'], + }, + + 'BooleanAttributePart accepts directive: ifDefined (defined)': { + render(v) { + return html`
` + }, + expectations: [ + { + args: ['foo'], + html: '', + }, + { + args: [undefined], + html: '
', + }, + ], + stableSelectors: ['div'], + }, + + 'BooleanAttributePart accepts directive: live': { + render(v: boolean) { + return html`
` + }, + expectations: [ + { + args: [true], + html: '', + }, + { + args: [false], + html: '
', + }, + ], + stableSelectors: ['div'], + }, + /****************************************************** * Mixed part tests ******************************************************/ @@ -1202,7 +2894,6 @@ export const tests: {[name: string] : SSRTest} = { }, 'NodeParts & AttributeParts on nested nodes': { - skip: true, render(x, y) { return html`
${x}
${y}
`; }, @@ -1220,7 +2911,6 @@ export const tests: {[name: string] : SSRTest} = { }, 'NodeParts & AttributeParts soup': { - skip: true, render(x, y, z) { return html`text:${x}
${x}
${x}

${y}

${z}
`; }, @@ -1237,4 +2927,534 @@ export const tests: {[name: string] : SSRTest} = { stableSelectors: ['div', 'span', 'p'], }, + 'All part types with at various depths': () => { + const handler1 = (e: Event) => (e.target as any).triggered1 = true; + const handler2 = (e: Event) => (e.target as any).triggered2 = true; + const checkDiv = (assert: Chai.Assert, dom: HTMLElement, id: string, x: any, triggerProp: string) => { + const div = dom.querySelector(`#${id}`) as HTMLElement; + assert.ok(div, `Div ${id} not found`); + div.click(); + assert.equal((div as any)[triggerProp], true, `Event not triggered for ${id}`); + assert.equal((div as any).p, x, `Property not set for ${id}`); + }; + const dirMap: WeakMap = new WeakMap(); + const dir = directive((value: string) => (part: Part) => { + if (dirMap.get(part) !== value) { + part.setValue(value ? `[${value}]` : value); + dirMap.set(part, value); + } + }); + const check = (assert: Chai.Assert, dom: HTMLElement, x: any, triggerProp: string) => { + for (let i=0; i<2; i++) { + checkDiv(assert, dom, `div${i}`, x, triggerProp); + for (let j=0; j<2; j++) { + checkDiv(assert, dom, `div${i}-${j}`, x, triggerProp); + for (let k=0; k<3; k++) { + checkDiv(assert, dom, `div${i}-${j}-${k}`, x, triggerProp); + } + } + } + }; + return { + render(x, y, z, h) { + return html` +
+ ${x} +
+ ${y} +
+ ${z} +
+
+ ${z} +
+ static +
+ ${z} +
+
+ static + static +
+ ${y} +
+ ${z} +
+
+ ${z} +
+ static +
+ ${z} +
+
+
+
+ ${x} +
+ ${y} +
+ ${z} +
+
+ ${z} +
+ static +
+ ${z} +
+
+ static + static +
+ ${y} +
+ ${z} +
+
+ ${z} +
+ static +
+ ${z} +
+
+
+ `; + }, + expectations: [ + { + args: ['x', 'y', html`z`, handler1], + html: ` +
+ x +
+ y +
+ z +
+
+ z +
+ static +
+ z +
+
+ static + static +
+ y +
+ z +
+
+ z +
+ static +
+ z +
+
+
+
+ x +
+ y +
+ z +
+
+ z +
+ static +
+ z +
+
+ static + static +
+ y +
+ z +
+
+ z +
+ static +
+ z +
+
+
`, + check(assert: Chai.Assert, dom: HTMLElement) { + check(assert, dom, 'x', 'triggered1'); + } + }, + { + args: [0, 1, html`2`, handler2], + html: ` +
+ 0 +
+ 1 +
+ 2 +
+
+ 2 +
+ static +
+ 2 +
+
+ static + static +
+ 1 +
+ 2 +
+
+ 2 +
+ static +
+ 2 +
+
+
+
+ 0 +
+ 1 +
+ 2 +
+
+ 2 +
+ static +
+ 2 +
+
+ static + static +
+ 1 +
+ 2 +
+
+ 2 +
+ static +
+ 2 +
+
+
`, + check(assert: Chai.Assert, dom: HTMLElement) { + check(assert, dom, 0, 'triggered2'); + } + }, + ], + stableSelectors: ['div', 'span'], + } + }, + + /****************************************************** + * LitElement tests + ******************************************************/ + + 'LitElement: Basic': () => { + return { + registerElements() { + customElements.define('le-basic', class extends LitElement { + render() { + return html`
[le-basic: ]
`; + } + }); + }, + render(x: string) { + return html`${x}`; + }, + expectations: [ + { + args: ['x'], + html: { + root: `x`, + 'le-basic': `
[le-basic: ]
` + }, + }, + ], + stableSelectors: ['le-basic'], + }; + }, + + 'LitElement: Nested': () => { + return { + registerElements() { + customElements.define('le-nested1', class extends LitElement { + render() { + return html`
[le-nested1: ]
`; + } + }); + customElements.define('le-nested2', class extends LitElement { + render() { + return html`
[le-nested2: ]
`; + } + }); + }, + render(x: string) { + return html`${x}`; + }, + expectations: [ + { + args: ['x'], + html: { + root: `x`, + 'le-nested1': { + root: `
[le-nested1: ]
`, + 'le-nested2': `
[le-nested2: ]
` + } + }, + }, + ], + stableSelectors: ['le-nested1'], + }; + }, + + 'LitElement: Property binding': () => { + return { + registerElements() { + class LEPropBinding extends LitElement { + @property() + prop = 'default'; + render() { + return html`
[${this.prop}]
`; + } + } + customElements.define('le-prop-binding', LEPropBinding); + }, + render(prop: any) { + return html``; + }, + expectations: [ + { + args: ['boundProp1'], + async check(assert: Chai.Assert, dom: HTMLElement) { + const el = dom.querySelector('le-prop-binding')! as LitElement; + await el.updateComplete; + assert.strictEqual((el as any).prop, 'boundProp1'); + }, + html: { + root: ``, + 'le-prop-binding': `
\n [\n boundProp1\n ]\n
` + }, + }, + { + args: ['boundProp2'], + async check(assert: Chai.Assert, dom: HTMLElement) { + const el = dom.querySelector('le-prop-binding')! as LitElement; + await el.updateComplete; + assert.strictEqual((el as any).prop, 'boundProp2'); + }, + html: { + root: ``, + 'le-prop-binding': `
\n [\n boundProp2\n ]\n
` + }, + }, + ], + stableSelectors: ['le-prop-binding'], + }; + }, + + 'LitElement: Reflected property binding': () => { + return { + // TODO: lit-element-renderer does not yet support reflected properties; + // should the renderer call into `addPropertiesForElement`? The first time + // a renderer is created for a given element type? + // https://github.com/PolymerLabs/lit-ssr/issues/61 + skip: true, + registerElements() { + class LEReflectedBinding extends LitElement { + @property({reflect: true}) + prop = 'default'; + render() { + return html`
[${this.prop}]
`; + } + } + customElements.define('le-reflected-binding', LEReflectedBinding); + }, + render(prop: any) { + return html``; + }, + expectations: [ + { + args: ['boundProp1'], + async check(assert: Chai.Assert, dom: HTMLElement) { + const el = dom.querySelector('le-reflected-binding')! as LitElement; + await el.updateComplete; + assert.strictEqual((el as any).prop, 'boundProp1'); + }, + html: { + root: ``, + 'le-reflected-binding': `
\n [\n boundProp1\n ]\n
` + }, + }, + { + args: ['boundProp2'], + async check(assert: Chai.Assert, dom: HTMLElement) { + const el = dom.querySelector('le-prop-binding')! as LitElement; + await el.updateComplete; + assert.strictEqual((el as any).prop, 'boundProp2'); + }, + html: { + root: ``, + 'le-reflected-binding': `
\n [\n boundProp2\n ]\n
` + }, + }, + ], + stableSelectors: ['le-reflected-binding'], + }; + }, + + 'LitElement: Attribute binding': () => { + return { + registerElements() { + class LEAttrBinding extends LitElement { + @property() + prop = 'default'; + render() { + return html`
[${this.prop}]
`; + } + } + customElements.define('le-attr-binding', LEAttrBinding); + }, + render(prop: any) { + return html``; + }, + expectations: [ + { + args: ['boundProp1'], + async check(_assert: Chai.Assert, dom: HTMLElement) { + const el = dom.querySelector('le-attr-binding')! as LitElement; + await el.updateComplete; + }, + html: { + root: ``, + 'le-attr-binding': `
\n [\n boundProp1\n ]\n
` + }, + }, + { + args: ['boundProp2'], + async check(_assert: Chai.Assert, dom: HTMLElement) { + const el = dom.querySelector('le-attr-binding')! as LitElement; + await el.updateComplete; + }, + html: { + root: ``, + 'le-attr-binding': `
\n [\n boundProp2\n ]\n
` + }, + }, + ], + stableSelectors: ['le-attr-binding'], + }; + }, + + 'LitElement: TemplateResult->Node binding': () => { + return { + registerElements() { + class LENodeBinding extends LitElement { + @property() + template: string | TemplateResult = 'default'; + render() { + return html`
${this.template}
`; + } + } + customElements.define('le-node-binding', LENodeBinding); + }, + render(template: (s: string) => TemplateResult) { + return html`${template('light')}`; + }, + expectations: [ + { + args: [(s: string) => html`[template1: ${s}]`], + async check(_assert: Chai.Assert, dom: HTMLElement) { + const el = dom.querySelector('le-node-binding')! as LitElement; + await el.updateComplete; + }, + html: { + root: `\n [template1:\n light\n ]\n`, + 'le-node-binding': `
\n [template1:\n shadow\n ]\n
` + }, + }, + { + args: [(s: string) => html`[template2: ${s}]`], + async check(_assert: Chai.Assert, dom: HTMLElement) { + const el = dom.querySelector('le-node-binding')! as LitElement; + await el.updateComplete; + }, + html: { + root: `\n [template2:\n light\n ]\n`, + 'le-node-binding': `
\n [template2:\n shadow\n ]\n
` + }, + }, + ], + stableSelectors: ['le-node-binding'], + }; + }, + + 'LitElement: renderLight': () => { + return { + registerElements() { + class LERenderLight extends LitElement { + @property() + prop = 'default'; + render() { + return html`
[shadow:${this.prop}]
`; + } + renderLight() { + return html`
[light:${this.prop}]
`; + } + } + customElements.define('le-render-light', LERenderLight); + }, + render(prop: any) { + return html`${renderLight()}`; + }, + expectations: [ + { + args: ['boundProp1'], + async check(assert: Chai.Assert, dom: HTMLElement) { + const el = dom.querySelector('le-render-light')! as LitElement; + await el.updateComplete; + assert.strictEqual((el as any).prop, 'boundProp1'); + }, + html: { + root: `\n
\n [light:\n boundProp1\n ]\n
\n
`, + 'le-render-light': `
\n [shadow:\n boundProp1\n \n ]\n
` + }, + }, + { + args: ['boundProp2'], + async check(assert: Chai.Assert, dom: HTMLElement) { + const el = dom.querySelector('le-render-light')! as LitElement; + await el.updateComplete; + assert.strictEqual((el as any).prop, 'boundProp2'); + }, + html: { + root: `\n
\n [light:\n boundProp2\n ]\n
\n
`, + 'le-render-light': `
\n [shadow:\n boundProp2\n \n ]\n
` + }, + }, + ], + stableSelectors: ['le-render-light'], + }; + }, + }; diff --git a/src/test/integration/tests/ssr-test.ts b/src/test/integration/tests/ssr-test.ts index 98e3071..caf53b1 100644 --- a/src/test/integration/tests/ssr-test.ts +++ b/src/test/integration/tests/ssr-test.ts @@ -13,7 +13,10 @@ */ import { TemplateResult } from 'lit-html'; -export interface SSRTest { + +export type SSRExpectedHTML = string | {[name: string] : SSRExpectedHTML | SSRExpectedHTML[]}; + +export interface SSRTestDescription { render(...args: any): TemplateResult; expectations: Array<{ @@ -27,9 +30,11 @@ export interface SSRTest { * * Does not need to contain lit-html marker comments. */ - html: string; + html: SSRExpectedHTML; + + setup?(assert: Chai.Assert, dom: HTMLElement): void | Promise + check?(assert: Chai.Assert, dom: HTMLElement): void | Promise; - check?(assert: Chai.Assert, dom: HTMLElement): void; }>; /** * A list of selectors of elements that should no change between renders. @@ -37,6 +42,13 @@ export interface SSRTest { */ stableSelectors: Array; expectMutationsOnFirstRender?: boolean, + expectMutationsDuringHydration?: boolean, + expectMutationsDuringUpgrade?: boolean, skip?: boolean; only?: boolean; + registerElements?() : void | Promise; } + +export type SSRTestFactory = () => SSRTestDescription; + +export type SSRTest = SSRTestDescription | SSRTestFactory; diff --git a/src/test/test-files/render-test-module.ts b/src/test/test-files/render-test-module.ts index 73d99d9..4341881 100644 --- a/src/test/test-files/render-test-module.ts +++ b/src/test/test-files/render-test-module.ts @@ -13,8 +13,8 @@ */ import {html, nothing} from 'lit-html'; -import {repeat} from '../../lib/directives/repeat.js'; -import {classMap} from '../../lib/directives/class-map.js'; +import {repeat} from 'lit-html/directives/repeat.js'; +import {classMap} from 'lit-html/directives/class-map.js'; import {LitElement, css, property, customElement} from 'lit-element'; export {render, getScopedStyles} from '../../lib/render-lit-html.js';