From 02b49a1bb413be4250f7e4a0e381ccda7efa1a0f Mon Sep 17 00:00:00 2001 From: Tan Li Hau Date: Fri, 30 Apr 2021 23:36:36 +0800 Subject: [PATCH] implement passing CSS custom properties to components (#6237) --- src/compiler/compile/nodes/InlineComponent.ts | 5 + .../wrappers/InlineComponent/index.ts | 66 ++++- .../render_ssr/handlers/InlineComponent.ts | 15 ++ test/helpers.ts | 8 + test/runtime-puppeteer/assert.js | 54 ++++ test/runtime-puppeteer/index.ts | 250 ++++++++++++++++++ .../Slider.svelte | 17 ++ .../_config.js | 41 +++ .../main.svelte | 23 ++ .../Slider.svelte | 17 ++ .../_config.js | 27 ++ .../main.svelte | 15 ++ test/server-side-rendering/index.ts | 147 +++++----- 13 files changed, 610 insertions(+), 75 deletions(-) create mode 100644 test/runtime-puppeteer/assert.js create mode 100644 test/runtime-puppeteer/index.ts create mode 100644 test/runtime-puppeteer/samples/component-css-custom-properties-dynamic/Slider.svelte create mode 100644 test/runtime-puppeteer/samples/component-css-custom-properties-dynamic/_config.js create mode 100644 test/runtime-puppeteer/samples/component-css-custom-properties-dynamic/main.svelte create mode 100644 test/runtime-puppeteer/samples/component-css-custom-properties/Slider.svelte create mode 100644 test/runtime-puppeteer/samples/component-css-custom-properties/_config.js create mode 100644 test/runtime-puppeteer/samples/component-css-custom-properties/main.svelte diff --git a/src/compiler/compile/nodes/InlineComponent.ts b/src/compiler/compile/nodes/InlineComponent.ts index 9990a8302d..c78f434527 100644 --- a/src/compiler/compile/nodes/InlineComponent.ts +++ b/src/compiler/compile/nodes/InlineComponent.ts @@ -18,6 +18,7 @@ export default class InlineComponent extends Node { bindings: Binding[] = []; handlers: EventHandler[] = []; lets: Let[] = []; + css_custom_properties: Attribute[] = []; children: INode[]; scope: TemplateScope; @@ -46,6 +47,10 @@ export default class InlineComponent extends Node { }); case 'Attribute': + if (node.name.startsWith('--')) { + this.css_custom_properties.push(new Attribute(component, this, scope, node)); + break; + } // fallthrough case 'Spread': this.attributes.push(new Attribute(component, this, scope, node)); diff --git a/src/compiler/compile/render_dom/wrappers/InlineComponent/index.ts b/src/compiler/compile/render_dom/wrappers/InlineComponent/index.ts index c6299321f4..2ea149a848 100644 --- a/src/compiler/compile/render_dom/wrappers/InlineComponent/index.ts +++ b/src/compiler/compile/render_dom/wrappers/InlineComponent/index.ts @@ -18,6 +18,7 @@ import { extract_names } from 'periscopic'; import mark_each_block_bindings from '../shared/mark_each_block_bindings'; import { string_to_member_expression } from '../../../utils/string_to_member_expression'; import SlotTemplate from '../../../nodes/SlotTemplate'; +import { is_head } from '../shared/is_head'; type SlotDefinition = { block: Block; scope: TemplateScope; get_context?: Node; get_changes?: Node }; @@ -63,6 +64,10 @@ export default class InlineComponentWrapper extends Wrapper { } }); + this.node.css_custom_properties.forEach(attr => { + block.add_dependencies(attr.dependencies); + }); + this.var = { type: 'Identifier', name: ( @@ -146,6 +151,12 @@ export default class InlineComponentWrapper extends Wrapper { } } + const has_css_custom_properties = this.node.css_custom_properties.length > 0; + const css_custom_properties_wrapper = has_css_custom_properties ? block.get_unique_name('div') : null; + if (has_css_custom_properties) { + block.add_variable(css_custom_properties_wrapper); + } + const initial_props = this.slots.size > 0 ? [ p`$$slots: { @@ -491,17 +502,64 @@ export default class InlineComponentWrapper extends Wrapper { ${munged_handlers} `); + if (has_css_custom_properties) { + block.chunks.create.push(b`${css_custom_properties_wrapper} = @element("div");`); + block.chunks.hydrate.push(b`@set_style(${css_custom_properties_wrapper}, "display", "contents");`); + this.node.css_custom_properties.forEach(attr => { + const dependencies = attr.get_dependencies(); + const should_cache = attr.should_cache(); + const last = should_cache && block.get_unique_name(`${attr.name.replace(/[^a-zA-Z_$]/g, '_')}_last`); + if (should_cache) block.add_variable(last); + const value = attr.get_value(block); + const init = should_cache ? x`${last} = ${value}` : value; + + block.chunks.hydrate.push(b`@set_style(${css_custom_properties_wrapper}, "${attr.name}", ${init});`); + if (dependencies.length > 0) { + let condition = block.renderer.dirty(dependencies); + if (should_cache) condition = x`${condition} && (${last} !== (${last} = ${value}))`; + + block.chunks.update.push(b` + if (${condition}) { + @set_style(${css_custom_properties_wrapper}, "${attr.name}", ${should_cache ? last : value}); + } + `); + } + }); + } block.chunks.create.push(b`@create_component(${name}.$$.fragment);`); if (parent_nodes && this.renderer.options.hydratable) { + let nodes = parent_nodes; + if (has_css_custom_properties) { + nodes = block.get_unique_name(`${css_custom_properties_wrapper.name}_nodes`); + block.chunks.claim.push(b` + ${css_custom_properties_wrapper} = @claim_element(${parent_nodes}, "DIV", { style: true }) + var ${nodes} = @children(${css_custom_properties_wrapper}); + `); + } block.chunks.claim.push( - b`@claim_component(${name}.$$.fragment, ${parent_nodes});` + b`@claim_component(${name}.$$.fragment, ${nodes});` ); } - block.chunks.mount.push( - b`@mount_component(${name}, ${parent_node || '#target'}, ${parent_node ? 'null' : '#anchor'});` - ); + if (has_css_custom_properties) { + if (parent_node) { + block.chunks.mount.push(b`@append(${parent_node}, ${css_custom_properties_wrapper})`); + if (is_head(parent_node)) { + block.chunks.destroy.push(b`@detach(${css_custom_properties_wrapper});`); + } + } else { + block.chunks.mount.push(b`@insert(#target, ${css_custom_properties_wrapper}, #anchor);`); + // TODO we eventually need to consider what happens to elements + // that belong to the same outgroup as an outroing element... + block.chunks.destroy.push(b`if (detaching) @detach(${css_custom_properties_wrapper});`); + } + block.chunks.mount.push(b`@mount_component(${name}, ${css_custom_properties_wrapper}, null);`); + } else { + block.chunks.mount.push( + b`@mount_component(${name}, ${parent_node || '#target'}, ${parent_node ? 'null' : '#anchor'});` + ); + } block.chunks.intro.push(b` @transition_in(${name}.$$.fragment, #local); diff --git a/src/compiler/compile/render_ssr/handlers/InlineComponent.ts b/src/compiler/compile/render_ssr/handlers/InlineComponent.ts index c4116e6591..2b3ca5fbcb 100644 --- a/src/compiler/compile/render_ssr/handlers/InlineComponent.ts +++ b/src/compiler/compile/render_ssr/handlers/InlineComponent.ts @@ -1,4 +1,5 @@ import { string_literal } from '../../utils/stringify'; +import { get_attribute_value } from './shared/get_attribute_value'; import Renderer, { RenderOptions } from '../Renderer'; import InlineComponent from '../../nodes/InlineComponent'; import { p, x } from 'code-red'; @@ -86,5 +87,19 @@ export default function(node: InlineComponent, renderer: Renderer, options: Rend ${slot_fns} }`; + if (node.css_custom_properties.length > 0) { + renderer.add_string('
'); + } + renderer.add_expression(x`@validate_component(${expression}, "${node.name}").$$render($$result, ${props}, ${bindings}, ${slots})`); + + if (node.css_custom_properties.length > 0) { + renderer.add_string('
'); + } } diff --git a/test/helpers.ts b/test/helpers.ts index 78f000c274..4de53e6cf9 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -260,3 +260,11 @@ export function mkdirp(dir) { // do nothing } } + +export function prettyPrintPuppeteerAssertionError(message) { + const match = /Error: Expected "(.+)" to equal "(.+)"/.exec(message); + + if (match) { + assert.equal(match[1], match[2]); + } +} diff --git a/test/runtime-puppeteer/assert.js b/test/runtime-puppeteer/assert.js new file mode 100644 index 0000000000..4ee8d9dda0 --- /dev/null +++ b/test/runtime-puppeteer/assert.js @@ -0,0 +1,54 @@ +export function deepEqual(a, b, message) { + if (!is_equal(a, b)) { + throw new Error(message || `Expected ${JSON.stringify(a)} to equal ${JSON.stringify(b)}`); + } +} + +function is_equal(a, b) { + if (a && typeof a === 'object') { + const is_array = Array.isArray(a); + if (Array.isArray(b) !== is_array) return false; + + if (is_array) { + if (a.length !== b.length) return false; + return a.every((value, i) => is_equal(value, b[i])); + } + + const a_keys = Object.keys(a).sort(); + const b_keys = Object.keys(b).sort(); + if (a_keys.join(',') !== b_keys.join(',')) return false; + + return a_keys.every(key => is_equal(a[key], b[key])); + } + + return a === b; +} + +export function equal(a, b, message) { + if (a != b) throw new Error(message || `Expected ${a} to equal ${b}`); +} + +export function ok(condition, message) { + if (!condition) throw new Error(message || `Expected ${condition} to be truthy`); +} + +export function htmlEqual(actual, expected, message) { + return deepEqual( + normalizeHtml(window, actual), + normalizeHtml(window, expected), + message + ); +} + +function normalizeHtml(window, html) { + try { + const node = window.document.createElement('div'); + node.innerHTML = html + .replace(//g, '') + .replace(/>[\s\r\n]+<') + .trim(); + return node.innerHTML.replace(/<\/?noscript\/?>/g, ''); + } catch (err) { + throw new Error(`Failed to normalize HTML:\n${html}`); + } +} diff --git a/test/runtime-puppeteer/index.ts b/test/runtime-puppeteer/index.ts new file mode 100644 index 0000000000..b700d47945 --- /dev/null +++ b/test/runtime-puppeteer/index.ts @@ -0,0 +1,250 @@ +import * as path from 'path'; +import * as fs from 'fs'; +import * as http from 'http'; +import { rollup } from 'rollup'; +import virtual from '@rollup/plugin-virtual'; +import puppeteer from 'puppeteer'; + +import { + loadConfig, + loadSvelte, + mkdirp, + prettyPrintPuppeteerAssertionError +} from '../helpers'; +import { deepEqual } from 'assert'; + +const page = ` + +
+ + +`; + +let svelte; +let server; +let code; +let browser; + +const internal = path.resolve('internal/index.mjs'); +const index = path.resolve('index.mjs'); + +function create_server() { + return new Promise((fulfil, reject) => { + const server = http.createServer((req, res) => { + if (req.url === '/') { + res.end(page); + } + + if (req.url === '/bundle.js') { + res.end(code); + } + }); + + server.on('error', reject); + + server.listen('6789', () => { + fulfil(server); + }); + }); +} + +const assert = fs.readFileSync(`${__dirname}/assert.js`, 'utf-8'); + +describe('runtime (puppeteer)', () => { + before(async () => { + svelte = loadSvelte(false); + console.log('[runtime-puppeteer] Loaded Svelte'); + server = await create_server(); + console.log('[runtime-puppeteer] Started server'); + browser = await puppeteer.launch(); + console.log('[runtime-puppeteer] Launched puppeteer browser'); + }); + + after(async () => { + if (server) server.close(); + if (browser) await browser.close(); + }); + + const failed = new Set(); + + function runTest(dir, hydrate) { + if (dir[0] === '.') return; + + const config = loadConfig(`${__dirname}/samples/${dir}/_config.js`); + const solo = config.solo || /\.solo/.test(dir); + const skip = config.skip || /\.skip/.test(dir); + + if (hydrate && config.skip_if_hydrate) return; + + if (solo && process.env.CI) { + throw new Error('Forgot to remove `solo: true` from test'); + } + + (skip ? it.skip : solo ? it.only : it)(`${dir} ${hydrate ? '(with hydration)' : ''}`, async () => { + if (failed.has(dir)) { + // this makes debugging easier, by only printing compiled output once + throw new Error('skipping test, already failed'); + } + + const warnings = []; + + const bundle = await rollup({ + input: 'main', + plugins: [ + { + name: 'testing-runtime-puppeteer', + resolveId(importee) { + if (importee === 'svelte/internal' || importee === './internal') { + return internal; + } + + if (importee === 'svelte') { + return index; + } + + if (importee === 'main') { + return 'main'; + } + }, + load(id) { + if (id === 'main') { + return ` + import SvelteComponent from ${JSON.stringify(path.join(__dirname, 'samples', dir, 'main.svelte'))}; + import config from ${JSON.stringify(path.join(__dirname, 'samples', dir, '_config.js'))}; + import * as assert from 'assert'; + + export default async function (target) { + let unhandled_rejection = false; + function unhandled_rejection_handler(event) { + unhandled_rejection = event.reason; + } + window.addEventListener('unhandledrejection', unhandled_rejection_handler); + + try { + if (config.before_test) config.before_test(); + + const options = Object.assign({}, { + target, + hydrate: ${String(!!hydrate)}, + props: config.props, + intro: config.intro + }, config.options || {}); + + const component = new SvelteComponent(options); + + if (config.html) { + assert.htmlEqual(target.innerHTML, config.html); + } + + if (config.test) { + await config.test({ + assert, + component, + target, + window, + }); + + component.$destroy(); + + if (unhandled_rejection) { + throw unhandled_rejection; + } + } else { + component.$destroy(); + assert.htmlEqual(target.innerHTML, ''); + + if (unhandled_rejection) { + throw unhandled_rejection; + } + } + + if (config.after_test) config.after_test(); + } catch (error) { + if (config.error) { + assert.equal(err.message, config.error); + } else { + throw error; + } + } finally { + window.removeEventListener('unhandledrejection', unhandled_rejection_handler); + } + } + `; + } + return null; + }, + transform(code, id) { + if (id.endsWith('.svelte')) { + const compiled = svelte.compile(code.replace(/\r/g, ''), { + ...config.compileOptions, + hydratable: hydrate, + immutable: config.immutable, + accessors: 'accessors' in config ? config.accessors : true + }); + + const out_dir = `${__dirname}/samples/${dir}/_output/${hydrate ? 'hydratable' : 'normal'}`; + const out = `${out_dir}/${path.basename(id).replace(/\.svelte$/, '.js')}`; + + if (fs.existsSync(out)) { + fs.unlinkSync(out); + } + + mkdirp(out_dir); + fs.writeFileSync(out, compiled.js.code, 'utf8'); + + compiled.warnings.forEach(w => warnings.push(w)); + + return compiled.js; + } + } + }, + virtual({ assert }) + ] + }); + + const result = await bundle.generate({ format: 'iife', name: 'test' }); + code = result.output[0].code; + + const page = await browser.newPage(); + + page.on('console', (type) => { + console[type._type](type._text); + }); + + page.on('error', error => { + console.log('>>> an error happened'); + console.error(error); + }); + + try { + await page.goto('http://localhost:6789'); + + const result = await page.evaluate(() => test(document.querySelector('main'))); + if (result) console.log(result); + } catch (err) { + failed.add(dir); + prettyPrintPuppeteerAssertionError(err.message); + throw err; + } finally { + if (config.warnings) { + deepEqual(warnings.map(w => ({ + code: w.code, + message: w.message, + pos: w.pos, + start: w.start, + end: w.end + })), config.warnings); + } else if (warnings.length) { + failed.add(dir); + /* eslint-disable no-unsafe-finally */ + throw new Error('Received unexpected warnings'); + } + } + }); + } + + fs.readdirSync(`${__dirname}/samples`).forEach(dir => { + runTest(dir, false); + runTest(dir, true); + }); +}); diff --git a/test/runtime-puppeteer/samples/component-css-custom-properties-dynamic/Slider.svelte b/test/runtime-puppeteer/samples/component-css-custom-properties-dynamic/Slider.svelte new file mode 100644 index 0000000000..4863966f85 --- /dev/null +++ b/test/runtime-puppeteer/samples/component-css-custom-properties-dynamic/Slider.svelte @@ -0,0 +1,17 @@ + + +
+

Slider

+ Track +
+ + \ No newline at end of file diff --git a/test/runtime-puppeteer/samples/component-css-custom-properties-dynamic/_config.js b/test/runtime-puppeteer/samples/component-css-custom-properties-dynamic/_config.js new file mode 100644 index 0000000000..96965c223b --- /dev/null +++ b/test/runtime-puppeteer/samples/component-css-custom-properties-dynamic/_config.js @@ -0,0 +1,41 @@ +export default { + props: { + railColor1: 'black', + trackColor1: 'red', + railColor2: 'green', + trackColor2: 'blue' + }, + html: ` +
+
+

Slider

+ Track +
+
+
+
+

Slider

+ Track +
+
+ `, + test({ component, assert, target }) { + component.railColor1 = 'yellow'; + component.trackColor2 = 'orange'; + + assert.htmlEqual(target.innerHTML, ` +
+
+

Slider

+ Track +
+
+
+
+

Slider

+ Track +
+
+ `); + } +}; diff --git a/test/runtime-puppeteer/samples/component-css-custom-properties-dynamic/main.svelte b/test/runtime-puppeteer/samples/component-css-custom-properties-dynamic/main.svelte new file mode 100644 index 0000000000..94e68e38a2 --- /dev/null +++ b/test/runtime-puppeteer/samples/component-css-custom-properties-dynamic/main.svelte @@ -0,0 +1,23 @@ + + + + + \ No newline at end of file diff --git a/test/runtime-puppeteer/samples/component-css-custom-properties/Slider.svelte b/test/runtime-puppeteer/samples/component-css-custom-properties/Slider.svelte new file mode 100644 index 0000000000..4863966f85 --- /dev/null +++ b/test/runtime-puppeteer/samples/component-css-custom-properties/Slider.svelte @@ -0,0 +1,17 @@ + + +
+

Slider

+ Track +
+ + \ No newline at end of file diff --git a/test/runtime-puppeteer/samples/component-css-custom-properties/_config.js b/test/runtime-puppeteer/samples/component-css-custom-properties/_config.js new file mode 100644 index 0000000000..9c3b7cbdea --- /dev/null +++ b/test/runtime-puppeteer/samples/component-css-custom-properties/_config.js @@ -0,0 +1,27 @@ +export default { + html: ` +
+
+

Slider

+ Track +
+
+
+
+

Slider

+ Track +
+
+ `, + test({ target, window, assert }) { + const railColor1 = target.querySelector('#slider-1 p'); + const trackColor1 = target.querySelector('#slider-1 span'); + const railColor2 = target.querySelector('#slider-2 p'); + const trackColor2 = target.querySelector('#slider-2 span'); + + assert.htmlEqual(window.getComputedStyle(railColor1).color, 'rgb(0, 0, 0)'); + assert.htmlEqual(window.getComputedStyle(trackColor1).color, 'rgb(255, 0, 0)'); + assert.htmlEqual(window.getComputedStyle(railColor2).color, 'rgb(0, 255, 0)'); + assert.htmlEqual(window.getComputedStyle(trackColor2).color, 'rgb(0, 0, 255)'); + } +}; diff --git a/test/runtime-puppeteer/samples/component-css-custom-properties/main.svelte b/test/runtime-puppeteer/samples/component-css-custom-properties/main.svelte new file mode 100644 index 0000000000..0d92b7ae87 --- /dev/null +++ b/test/runtime-puppeteer/samples/component-css-custom-properties/main.svelte @@ -0,0 +1,15 @@ + + + + + \ No newline at end of file diff --git a/test/server-side-rendering/index.ts b/test/server-side-rendering/index.ts index e81058181d..f455cecc72 100644 --- a/test/server-side-rendering/index.ts +++ b/test/server-side-rendering/index.ts @@ -135,100 +135,105 @@ describe('ssr', () => { }); // duplicate client-side tests, as far as possible - fs.readdirSync('test/runtime/samples').forEach(dir => { - if (dir[0] === '.') return; + runRuntimeSamples('runtime'); + runRuntimeSamples('runtime-puppeteer'); - const config = loadConfig(`./runtime/samples/${dir}/_config.js`); - const solo = config.solo || /\.solo/.test(dir); + function runRuntimeSamples(suite) { + fs.readdirSync(`test/${suite}/samples`).forEach(dir => { + if (dir[0] === '.') return; - if (solo && process.env.CI) { - throw new Error('Forgot to remove `solo: true` from test'); - } + const config = loadConfig(`./${suite}/samples/${dir}/_config.js`); + const solo = config.solo || /\.solo/.test(dir); - if (config.skip_if_ssr) return; + if (solo && process.env.CI) { + throw new Error('Forgot to remove `solo: true` from test'); + } - (config.skip ? it.skip : solo ? it.only : it)(dir, () => { - const cwd = path.resolve('test/runtime/samples', dir); + if (config.skip_if_ssr) return; - cleanRequireCache(); + (config.skip ? it.skip : solo ? it.only : it)(dir, () => { + const cwd = path.resolve(`test/${suite}/samples`, dir); - delete global.window; + cleanRequireCache(); - const compileOptions = { - sveltePath, - ...config.compileOptions, - generate: 'ssr', - format: 'cjs' - }; - - require('../../register')(compileOptions); + delete global.window; - glob('**/*.svelte', { cwd }).forEach(file => { - if (file[0] === '_') return; + const compileOptions = { + sveltePath, + ...config.compileOptions, + generate: 'ssr', + format: 'cjs' + }; - const dir = `${cwd}/_output/ssr`; - const out = `${dir}/${file.replace(/\.svelte$/, '.js')}`; + require('../../register')(compileOptions); - if (fs.existsSync(out)) { - fs.unlinkSync(out); - } + glob('**/*.svelte', { cwd }).forEach(file => { + if (file[0] === '_') return; - mkdirp(dir); + const dir = `${cwd}/_output/ssr`; + const out = `${dir}/${file.replace(/\.svelte$/, '.js')}`; - try { - const { js } = compile( - fs.readFileSync(`${cwd}/${file}`, 'utf-8'), - { - ...compileOptions, - filename: file - } - ); + if (fs.existsSync(out)) { + fs.unlinkSync(out); + } - fs.writeFileSync(out, js.code); - } catch (err) { - // do nothing - } - }); + mkdirp(dir); - try { - if (config.before_test) config.before_test(); + try { + const { js } = compile( + fs.readFileSync(`${cwd}/${file}`, 'utf-8'), + { + ...compileOptions, + filename: file + } + ); - const Component = require(`../runtime/samples/${dir}/main.svelte`).default; - const { html } = Component.render(config.props, { - store: (config.store !== true) && config.store + fs.writeFileSync(out, js.code); + } catch (err) { + // do nothing + } }); - if (config.ssrHtml) { - assert.htmlEqual(html, config.ssrHtml); - } else if (config.html) { - assert.htmlEqual(html, config.html); - } + try { + if (config.before_test) config.before_test(); - if (config.test_ssr) { - config.test_ssr({ assert }); - } + const Component = require(`../${suite}/samples/${dir}/main.svelte`).default; + const { html } = Component.render(config.props, { + store: (config.store !== true) && config.store + }); - if (config.after_test) config.after_test(); + if (config.ssrHtml) { + assert.htmlEqual(html, config.ssrHtml); + } else if (config.html) { + assert.htmlEqual(html, config.html); + } - if (config.show) { - showOutput(cwd, compileOptions); - } - } catch (err) { - err.stack += `\n\ncmd-click: ${path.relative(process.cwd(), cwd)}/main.svelte`; + if (config.test_ssr) { + config.test_ssr({ assert }); + } + + if (config.after_test) config.after_test(); - if (config.error) { - if (typeof config.error === 'function') { - config.error(assert, err); + if (config.show) { + showOutput(cwd, compileOptions); + } + } catch (err) { + err.stack += `\n\ncmd-click: ${path.relative(process.cwd(), cwd)}/main.svelte`; + + if (config.error) { + if (typeof config.error === 'function') { + config.error(assert, err); + } else { + assert.equal(err.message, config.error); + } } else { - assert.equal(err.message, config.error); + showOutput(cwd, compileOptions); + throw err; } - } else { - showOutput(cwd, compileOptions); - throw err; + } finally { + set_current_component(null); } - } finally { - set_current_component(null); - } + }); }); - }); + } });