Skip to content

Commit

Permalink
feat: implement initial support for SSR
Browse files Browse the repository at this point in the history
  • Loading branch information
kyubisation committed Feb 23, 2024
1 parent f6ea9d6 commit 0b9a09d
Show file tree
Hide file tree
Showing 24 changed files with 863 additions and 201 deletions.
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
20.9.0
v20.11.0
97 changes: 97 additions & 0 deletions config/lit-ssr-worker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
import { parentPort, workerData } from 'worker_threads';
import { render } from '@lit-labs/ssr';
import { build } from 'vite';
import { createHash } from 'crypto';
import { dirname, join, relative, resolve } from 'path';

if (parentPort === null) {
throw new Error('worker.js must only be run in a worker thread');
}

const { template, modules } = workerData;
if (modules?.length) {
const buildCacheOutDir = new URL(`../dist/testout/${moduleHash(modules)}/`, import.meta.url);
const moduleIndex = new URL('index.js', buildCacheOutDir).pathname;
if (!existsSync(moduleIndex)) {
await buildModules(buildCacheOutDir);
}

await import(moduleIndex);
}
// Dangerously spoof TemplateStringsArray by adding back the \`raw\` property
// property which gets stripped during serialization of the TemplateResult.
// This is needed to get through the check here
// https://github.com/lit/lit/blob/1fbd2b7a1e6da09912f5c681d2b6eaf1c4920bb4/packages/lit-html/src/lit-html.ts#L867
const strings = template.strings;
strings.raw = strings;
let rendered = '';
for (const str of render({ ...template, strings }, { deferHydration: true })) {
rendered += str;
}
parentPort.postMessage(rendered);

/**
* We are dynamically building the given modules, as Node.js cannot currently
* use loaders in workers.
* It works by creating a directory containing the hash
* of all used modules and (recursive) imports of these modules
* and building an index.js file, which is the result of the build.
*
* TODO: Remove once Node.js supports loading .ts files.
*/
async function buildModules(buildCacheOutDir) {
mkdirSync(buildCacheOutDir, { recursive: true });

const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
const entry = new URL('index.ts', buildCacheOutDir).pathname;
const importStatement = modules
.map((m) => `export * from '${relative(buildCacheOutDir.pathname, m)}';`)
.join('\n');
writeFileSync(entry, importStatement, 'utf8');

await build({
root: new URL('..', import.meta.url).pathname,
mode: 'development',
logLevel: 'warn',
build: {
lib: {
entry,
formats: ['es'],
},
ssr: true,
outDir: buildCacheOutDir.pathname,
emptyOutDir: false,
rollupOptions: { external: Object.keys({ ...pkg.dependencies, ...pkg.devDependencies }) },
sourcemap: 'inline',
},
});
}

/** Generate a hash from the contents of the given modules and their import chain. */
function moduleHash(modules) {
const fileMap = new Map();
modules.forEach((m) => resolveImports(m, fileMap));
return Array.from(fileMap)
.sort((a, b) => a[0].localeCompare(b[0]))
.map((f) => f[1])
.reduce((current, next) => current.update(next), createHash('sha256'))
.digest('hex');
}

/** Resolve all used import/files recursively starting from the given file. */
function resolveImports(file, fileMap) {
const content = readFileSync(file, 'utf8');
fileMap.set(file, content);

const dir = dirname(file);
[...content.matchAll(/from '(\.[^']+)|import '(.[^']+)/g)]
.map((m) => m[1] ?? m[2])
.filter((m) => !m.includes('?'))
.map((m) => resolve(dir, m))
.flatMap((m) => [`${m}.ts`, join(m, 'index.ts')].filter((f) => existsSync(f)))
.filter((v, i, a) => a.indexOf(v) === i)
.filter((m) => !fileMap.has(m))
.sort()
.forEach((m) => resolveImports(m, fileMap));
}
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
"lint:tsc:components": "tsc --noEmit --project src/components/tsconfig.json",
"lint:tsc:components-spec": "tsc --noEmit --project src/components/tsconfig.spec.json",
"start": "storybook dev -p 6006",
"test": "wtr --coverage --group default",
"test": "wtr --coverage",
"test:spec": "wtr --group spec",
"test:snapshot": "yarn test --ci --update-snapshots",
"test:e2e": "wtr --group e2e",
Expand All @@ -57,6 +57,7 @@
"@commitlint/config-conventional": "18.6.2",
"@custom-elements-manifest/analyzer": "0.9.2",
"@custom-elements-manifest/to-markdown": "0.1.0",
"@lit-labs/testing": "0.2.3",
"@lit/react": "^1.0.1",
"@open-wc/lit-helpers": "0.7.0",
"@open-wc/testing": "4.0.0",
Expand Down
129 changes: 129 additions & 0 deletions scripts/ssr-e2e.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { readFileSync, writeFileSync } from 'fs';
import { basename, dirname, join, relative } from 'path';

import * as glob from 'glob';
// eslint-disable-next-line @typescript-eslint/naming-convention
import MagicString from 'magic-string';
import ts from 'typescript';

function* iterate(node: ts.Node): Generator<ts.Node, void, unknown> {
yield node;
const childNodes: ts.Node[] = [];
ts.forEachChild(node, (n) => {
childNodes.push(n);
});
for (const childNode of childNodes) {
yield* iterate(childNode);
}
}

const testingDir = new URL('../src/components/core/testing', import.meta.url).pathname;
const e2eFiles = glob.sync('**/*.e2e.ts', { cwd: new URL('..', import.meta.url), absolute: true });
const componentIndexes = glob
.sync('src/components/**/index.ts', { cwd: new URL('..', import.meta.url), absolute: true })
.filter((f) => !f.includes('/components/core/'))
.sort()
.sort((a, b) => b.length - a.length);
for (const file of e2eFiles) {
const fileDir = dirname(file);
const content = readFileSync(file, 'utf8');
const sourceFile = ts.createSourceFile(file, content, ts.ScriptTarget.ES2021, true);

const imports: ts.ImportDeclaration[] = [];
const fixtureUsages: ts.CallExpression[] = [];
let testingImport: ts.ImportDeclaration | null = null;
let wcFixture: ts.ImportSpecifier | null = null;
for (const node of iterate(sourceFile)) {
if (ts.isImportDeclaration(node)) {
if (node.moduleSpecifier.getText() === `'@open-wc/testing'`) {
wcFixture =
(node.importClause?.namedBindings as ts.NamedImports).elements.find(
(e) => e.name.getText() === 'fixture',
) ?? null;
} else if (node.moduleSpecifier.getText().endsWith(`/core/testing'`)) {
testingImport = node;
} else if (
!node.moduleSpecifier.getText().includes('/core/') &&
node.moduleSpecifier.getText().startsWith(`'.`) &&
(!node.importClause || !node.importClause.isTypeOnly)
) {
imports.push(node);
}
} else if (
ts.isCallExpression(node) &&
ts.isIdentifier(node.expression) &&
node.expression.getText() === 'fixture'
) {
fixtureUsages.push(node);
}
}

if (wcFixture) {
const newContent = new MagicString(content);
newContent.remove(wcFixture.getStart(), wcFixture.getStart() + wcFixture.getWidth());
newContent.replaceAll(
/\ndescribe\(['"`]([^'"`]+)['"`]/g,
(_, m) => `\ndescribe(\`${m} with \${fixture.name}\``,
);
if (testingImport) {
newContent.appendRight(
testingImport.importClause!.getStart() + testingImport.importClause!.getWidth() - 1,
', fixture',
);
} else {
const reverseImports = imports.slice().reverse();
const lastRelativeImport =
reverseImports.find((i) => i.moduleSpecifier.getText().startsWith(`'..`)) ??
reverseImports.find((i) => i.moduleSpecifier.getText().startsWith(`'.`))!;
newContent.appendRight(
lastRelativeImport.getStart() + lastRelativeImport.getWidth(),
`\nimport { fixture } from '${relative(file, testingDir)}';`,
);
}

const missingImports = new Set<string>();
for (const fixture of fixtureUsages) {
const usedElements = [...fixture.arguments[0].getText().matchAll(/<sbb-([^\s>]+)/g)]
.map((m) => m[1])
.filter((v, i, a) => a.indexOf(v) === i);
const importPaths = usedElements
.map((e) => componentIndexes.find((i) => i.endsWith(`/${e}/index.ts`))!)
.map((path) => {
do {
const shortPath = join(dirname(dirname(path)), 'index.ts');
if (
fileDir === dirname(path) ||
!componentIndexes.includes(shortPath) ||
relative(fileDir, shortPath).length > relative(fileDir, path).length ||
['..', '../index.ts'].includes(relative(fileDir, shortPath))
) {
const relPath = relative(fileDir, path);
return `'${relPath.startsWith('.') ? relPath : `./${basename(dirname(file))}.ts`}'`;
}

path = shortPath;
// eslint-disable-next-line no-constant-condition
} while (true);
});

importPaths
.map((i) => i.replace(/.ts'$/, `'`).replace(/\/index'$/, `'`))
.filter((ip) => imports.every((i) => i.moduleSpecifier.getText() !== ip))
.forEach((i) => missingImports.add(i));

newContent.appendRight(
fixture!.getStart() + fixture!.getWidth() - 1,
`, { modules: [${importPaths.join(', ')}] }`,
);
}
if (missingImports.size) {
console.log(
`${file} is missing imports:\n${[...missingImports].map((i) => `- ${i}\n`).join('')}\n`,
);
}

writeFileSync(file, newContent.toString(), 'utf8');
}
}

console.log('Done');
61 changes: 33 additions & 28 deletions src/components/accordion/accordion.e2e.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,44 @@
import { assert, expect, fixture } from '@open-wc/testing';
import { assert, expect } from '@open-wc/testing';
import { nothing } from 'lit';
import { html } from 'lit/static-html.js';

import { waitForCondition, waitForLitRender, EventSpy } from '../core/testing';
import { SbbExpansionPanelElement } from '../expansion-panel';
import type { SbbExpansionPanelHeaderElement } from '../expansion-panel';
import { waitForCondition, waitForLitRender, EventSpy, fixture, isSsr } from '../core/testing';
import { SbbExpansionPanelElement, type SbbExpansionPanelHeaderElement } from '../expansion-panel';

import { SbbAccordionElement } from './accordion';

describe('sbb-accordion', () => {
describe(`sbb-accordion ${fixture.name}`, () => {
let element: SbbAccordionElement;

beforeEach(async () => {
element = await fixture(html`
<sbb-accordion title-level="4">
<sbb-expansion-panel id="panel-1" disable-animation>
<sbb-expansion-panel-header id="header-1">Header 1</sbb-expansion-panel-header>
<sbb-expansion-panel-content>Content 1</sbb-expansion-panel-content>
</sbb-expansion-panel>
<sbb-expansion-panel id="panel-2" disable-animation>
<sbb-expansion-panel-header id="header-2">Header 2</sbb-expansion-panel-header>
<sbb-expansion-panel-content>Content 2</sbb-expansion-panel-content>
</sbb-expansion-panel>
<sbb-expansion-panel id="panel-3" disable-animation>
<sbb-expansion-panel-header id="header-3">Header 3</sbb-expansion-panel-header>
<sbb-expansion-panel-content>Content 3</sbb-expansion-panel-content>
</sbb-expansion-panel>
</sbb-accordion>
`);
beforeEach(async function () {
const ssrTitleLevel = isSsr() ? '4' : nothing;
element = await fixture(
html`
<sbb-accordion title-level="4">
<sbb-expansion-panel id="panel-1" disable-animation title-level=${ssrTitleLevel}>
<sbb-expansion-panel-header id="header-1">Header 1</sbb-expansion-panel-header>
<sbb-expansion-panel-content>Content 1</sbb-expansion-panel-content>
</sbb-expansion-panel>
<sbb-expansion-panel id="panel-2" disable-animation title-level=${ssrTitleLevel}>
<sbb-expansion-panel-header id="header-2">Header 2</sbb-expansion-panel-header>
<sbb-expansion-panel-content>Content 2</sbb-expansion-panel-content>
</sbb-expansion-panel>
<sbb-expansion-panel id="panel-3" disable-animation title-level=${ssrTitleLevel}>
<sbb-expansion-panel-header id="header-3">Header 3</sbb-expansion-panel-header>
<sbb-expansion-panel-content>Content 3</sbb-expansion-panel-content>
</sbb-expansion-panel>
</sbb-accordion>
`,
{ modules: ['./accordion.ts', '../expansion-panel/index.ts'] },
);
await waitForLitRender(element);
});

it('renders', async () => {
it('renders', () => {
assert.instanceOf(element, SbbAccordionElement);
});

it('should set accordion context on expansion panel', async () => {
it('should set accordion context on expansion panel', () => {
const panels = Array.from(element.querySelectorAll('sbb-expansion-panel'));

expect(panels[0]).to.have.attribute('data-accordion-first');
Expand All @@ -47,30 +52,30 @@ describe('sbb-accordion', () => {
let panels: SbbExpansionPanelElement[];

element.querySelector('sbb-expansion-panel')!.remove();
await waitForLitRender(element);
await element.updateComplete;

panels = Array.from(element.querySelectorAll('sbb-expansion-panel'));
expect(panels[0]).to.have.attribute('data-accordion-first');
expect(panels[1]).to.have.attribute('data-accordion-last');

element.querySelector('sbb-expansion-panel')!.remove();
await waitForLitRender(element);
await element.updateComplete;

const lastRemainingPanel = element.querySelector('sbb-expansion-panel');
expect(lastRemainingPanel).to.have.attribute('data-accordion-first');
expect(lastRemainingPanel).to.have.attribute('data-accordion-last');

const panel = document.createElement('sbb-expansion-panel');
element.append(panel);
await waitForLitRender(element);
await element.updateComplete;

panels = Array.from(element.querySelectorAll('sbb-expansion-panel'));
expect(panels[0]).to.have.attribute('data-accordion-first');
expect(panels[0]).not.to.have.attribute('data-accordion-last');
expect(panels[1]).to.have.attribute('data-accordion-last');
});

it('should inherit titleLevel prop by panels', async () => {
it('should inherit titleLevel prop by panels', () => {
const panels = Array.from(element.querySelectorAll('sbb-expansion-panel'));
expect(panels.length).to.be.equal(3);
expect(
Expand Down
Loading

0 comments on commit 0b9a09d

Please sign in to comment.