Skip to content

Commit

Permalink
Emit a pseudo import for the co-located template
Browse files Browse the repository at this point in the history
As discussed in #1619, tools like babel caches the ouput based on
the source content of the input files. For component javascript
files, whether there is a co-located template file is an extra bit
of information that doesn't show up in the source file, but that
information does get used in producing the output. This causes the
caches to not invalidate when a co-located tempalte file is added
or deleted.

This fixes the problem by ensuring we include that information in
the input source file. For now, it is just an inert comment, but
we can actually adjust our babel plugin to rely on this information
rather than doing its own filesystem probing again, which should
have some performance benefit.
  • Loading branch information
chancancode committed Oct 8, 2023
1 parent 4ed95ed commit fdf7d4c
Show file tree
Hide file tree
Showing 2 changed files with 162 additions and 39 deletions.
89 changes: 61 additions & 28 deletions packages/compat/src/synthesize-template-only-components.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,27 @@
import Plugin from 'broccoli-plugin';
import type { Node } from 'broccoli-node-api';
import { join, basename } from 'path';
import walkSync from 'walk-sync';
import { removeSync, outputFileSync, pathExistsSync } from 'fs-extra';
import walkSync, { type Entry } from 'walk-sync';
import { removeSync, outputFileSync, pathExistsSync, readFileSync } from 'fs-extra';

const source = `import templateOnlyComponent from '@ember/component/template-only';
export default templateOnlyComponent();`;

const jsExtensions = ['.js', '.ts', '.mjs', '.mts'];

type Emitted = { type: 'template-only-component' } | { type: 'template-import'; mtime: number };

type TemplateOnly = { template: Entry; javascript: undefined };
type JavaScriptOnly = { template: undefined; javascript: Entry };
type Colocated = { template: Entry; javascript: Entry };
type ComponentFiles = TemplateOnly | JavaScriptOnly | Colocated;

function importTemplate(files: { template: Entry }): string {
return `/* import __COLOCATED_TEMPLATE__ from './${basename(files.template.relativePath)}'; */\n`;
}

export default class SynthesizeTemplateOnlyComponents extends Plugin {
private emitted = new Set() as Set<string>;
private emitted = new Map() as Map<string, Emitted>;
private allowedPaths: string[];
private templateExtensions: string[];

Expand All @@ -25,32 +36,50 @@ export default class SynthesizeTemplateOnlyComponents extends Plugin {
}

async build() {
let unneeded = new Set(this.emitted);
let unneeded = new Set(this.emitted.keys());
for (let dir of this.allowedPaths) {
let { needed, seen } = this.crawl(join(this.inputPaths[0], dir));
for (let file of needed) {
let fullName = join(this.outputPath, dir, file);
let entries = this.crawl(join(this.inputPaths[0], dir));
for (let [name, files] of entries) {
let fullName = join(this.outputPath, dir, name);
unneeded.delete(fullName);
if (seen.has(file)) {
this.remove(fullName);
if (files.javascript && files.template) {
this.addTemplateImport(fullName, files);
} else if (files.template) {
this.addTemplateOnlyComponent(fullName, files);
} else {
this.add(fullName);
this.remove(fullName);
}
}
}
for (let fullName of unneeded) {
this.remove(fullName);
}
}
private add(filename: string) {
if (!this.emitted.has(filename)) {

private addTemplateOnlyComponent(filename: string, files: TemplateOnly) {
if (this.emitted.get(filename)?.type !== 'template-only-component') {
// special case: ember-cli doesn't allow template-only components named
// "template.hbs" because there are too many people doing a "pods-like"
// layout that happens to match that pattern.🤮
if (basename(filename) !== 'template') {
outputFileSync(filename + '.js', source, 'utf8');
outputFileSync(filename + '.js', importTemplate(files) + source, 'utf8');
}
this.emitted.add(filename);

this.emitted.set(filename, { type: 'template-only-component' });
}
}

private addTemplateImport(filename: string, files: Colocated) {
const emitted = this.emitted.get(filename);
const mtime = files.javascript.mtime;

if (!(emitted?.type === 'template-import' && emitted.mtime === mtime)) {
const inputSource = readFileSync(files.javascript.fullPath, { encoding: 'utf8' });

// If we are ok with appending instead, copy + append maybe more efficient?
outputFileSync(filename + '.js', importTemplate(files) + inputSource, 'utf8');

this.emitted.set(filename, { type: 'template-import', mtime });
}
}

Expand All @@ -61,23 +90,27 @@ export default class SynthesizeTemplateOnlyComponents extends Plugin {
}
}

private crawl(dir: string) {
const needed = new Set<string>();
const seen = new Set<string>();
private crawl(dir: string): Map<string, ComponentFiles> {
const entries = new Map<string, ComponentFiles>();

if (pathExistsSync(dir)) {
for (let file of walkSync(dir, { directories: false })) {
for (const templateExtension of this.templateExtensions) {
if (file.endsWith(templateExtension)) {
needed.add(file.slice(0, -1 * templateExtension.length));
} else {
const jsExtension = jsExtensions.find(ext => file.endsWith(ext));
if (jsExtension) {
seen.add(file.slice(0, -1 * jsExtension.length));
}
}
for (let entry of walkSync.entries(dir, { directories: false })) {
const templateExtension = this.templateExtensions.find(ext => entry.relativePath.endsWith(ext));
if (templateExtension) {
const key = entry.relativePath.slice(0, -1 * templateExtension.length);
entries.set(key, { template: entry, javascript: entries.get(key)?.javascript });
continue;
}

const jsExtension = jsExtensions.find(ext => entry.relativePath.endsWith(ext));
if (jsExtension) {
const key = entry.relativePath.slice(0, -1 * jsExtension.length);
entries.set(key, { template: entries.get(key)?.template, javascript: entry });
continue;
}
}
}
return { needed, seen };

return entries;
}
}
112 changes: 101 additions & 11 deletions tests/scenarios/watch-mode-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,45 @@ class AssertFile {
}
}

function d(strings: TemplateStringsArray, ...values: unknown[]): string {
let buf = '';
for (let string of strings) {
if (values.length) {
buf += string + values.shift();
} else {
buf += string;
}
}
return deindent(buf);
}

function deindent(s: string): string {
if (s.startsWith('\n')) {
s = s.slice(1);
}

let indentSize = s.search(/\S/);

if (indentSize > 0) {
let indent = s.slice(0, indentSize);

s = s
.split('\n')
.map(line => {
if (line.startsWith(indent)) {
return line.slice(indentSize);
} else {
return line;
}
})
.join('\n');
}

s = s.trimEnd();

return s;
}

app.forEachScenario(scenario => {
Qmodule(scenario.name, function (hooks) {
let app: PreparedApp;
Expand Down Expand Up @@ -411,9 +450,11 @@ app.forEachScenario(scenario => {
await waitFor(/Build successful/);
await assertRewrittenFile('assets/app-template.js').includesContent('"app-template/components/hello-world"');
await assertRewrittenFile('components/hello-world.hbs').hasContent('hello world!');
await assertRewrittenFile('components/hello-world.js').includesContent(
'export default templateOnlyComponent();'
);
await assertRewrittenFile('components/hello-world.js').hasContent(d`
/* import __COLOCATED_TEMPLATE__ from './hello-world.hbs'; */
import templateOnlyComponent from '@ember/component/template-only';
export default templateOnlyComponent();
`);
server.clearOutput();

await appFile('app/components/hello-world.hbs').delete();
Expand All @@ -431,7 +472,7 @@ app.forEachScenario(scenario => {
await assertRewrittenFile('components/hello-world.js').doesNotExist();
await assertRewrittenFile('tests/integration/hello-world-test.js').doesNotExist();

await appFile('tests/integration/hello-world-test.js').write(`
await appFile('tests/integration/hello-world-test.js').write(d`
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
Expand All @@ -453,14 +494,14 @@ app.forEachScenario(scenario => {
await assertRewrittenFile('tests/integration/hello-world-test.js').includesContent('<HelloWorld />');
server.clearOutput();

await appFile('app/components/hello-world.js').write(`
await appFile('app/components/hello-world.js').write(d`
import Component from '@glimmer/component';
export default class extends Component {}
`);
await added('components/hello-world.js');
await waitFor(/Build successful/);
await assertRewrittenFile('components/hello-world.hbs').doesNotExist();
await assertRewrittenFile('components/hello-world.js').hasContent(`
await assertRewrittenFile('components/hello-world.js').hasContent(d`
import Component from '@glimmer/component';
export default class extends Component {}
`);
Expand All @@ -475,7 +516,8 @@ app.forEachScenario(scenario => {
await added('components/hello-world.hbs');
await waitFor(/Build successful/);
await assertRewrittenFile('components/hello-world.hbs').hasContent('hello world!');
await assertRewrittenFile('components/hello-world.js').hasContent(`
await assertRewrittenFile('components/hello-world.js').hasContent(d`
/* import __COLOCATED_TEMPLATE__ from './hello-world.hbs'; */
import Component from '@glimmer/component';
export default class extends Component {}
`);
Expand All @@ -494,17 +536,22 @@ app.forEachScenario(scenario => {
await added('components/hello-world.hbs');
await waitFor(/Build successful/);
await assertRewrittenFile('components/hello-world.hbs').hasContent('hello world!');
await assertRewrittenFile('components/hello-world.js').includesContent('templateOnlyComponent();');
await assertRewrittenFile('components/hello-world.js').hasContent(d`
/* import __COLOCATED_TEMPLATE__ from './hello-world.hbs'; */
import templateOnlyComponent from '@ember/component/template-only';
export default templateOnlyComponent();
`);
server.clearOutput();

await appFile('app/components/hello-world.js').write(`
await appFile('app/components/hello-world.js').write(d`
import Component from '@glimmer/component';
export default class extends Component {}
`);
await added('components/hello-world.js');
await waitFor(/Build successful/);
await assertRewrittenFile('components/hello-world.hbs').hasContent('hello world!');
await assertRewrittenFile('components/hello-world.js').hasContent(`
await assertRewrittenFile('components/hello-world.js').hasContent(d`
/* import __COLOCATED_TEMPLATE__ from './hello-world.hbs'; */
import Component from '@glimmer/component';
export default class extends Component {}
`);
Expand All @@ -514,11 +561,54 @@ app.forEachScenario(scenario => {
await deleted('components/hello-world.hbs');
await waitFor(/Build successful/);
await assertRewrittenFile('components/hello-world.hbs').doesNotExist();
await assertRewrittenFile('components/hello-world.js').hasContent(`
await assertRewrittenFile('components/hello-world.js').hasContent(d`
import Component from '@glimmer/component';
export default class extends Component {}
`);
});

test('Scenario 4: editing a co-located js file', async function () {
await assertRewrittenFile('components/hello-world.hbs').doesNotExist();
await assertRewrittenFile('components/hello-world.js').doesNotExist();

await appFile('app/components/hello-world.hbs').write('hello world!');
await added('components/hello-world.hbs');
await waitFor(/Build successful/);
server.clearOutput();

await appFile('app/components/hello-world.js').write(d`
import Component from '@glimmer/component';
export default class extends Component {}
`);
await added('components/hello-world.js');
await waitFor(/Build successful/);
server.clearOutput();

await assertRewrittenFile('components/hello-world.hbs').hasContent('hello world!');
await assertRewrittenFile('components/hello-world.js').hasContent(d`
/* import __COLOCATED_TEMPLATE__ from './hello-world.hbs'; */
import Component from '@glimmer/component';
export default class extends Component {}
`);

await appFile('app/components/hello-world.js').write(d`
import Component from '@glimmer/component';
export default class extends Component {
// this shows that updates invalidate any caches and reflects properly
}
`);
await changed('components/hello-world.js');
await waitFor(/Build successful/);

await assertRewrittenFile('components/hello-world.hbs').hasContent('hello world!');
await assertRewrittenFile('components/hello-world.js').hasContent(d`
/* import __COLOCATED_TEMPLATE__ from './hello-world.hbs'; */
import Component from '@glimmer/component';
export default class extends Component {
// this shows that updates invalidate any caches and reflects properly
}
`);
});
});
});
});

0 comments on commit fdf7d4c

Please sign in to comment.