Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(www): fix an inconsistency between www builds #4983

Merged
merged 1 commit into from
Oct 27, 2023

Conversation

alicewriteswrongs
Copy link
Contributor

This fixes a bug where subsequent www builds would not always produce working output. In particular, prior to this change a 'clean' www build (i.e one done after deleting all previous output) would produce a working build but subsequent builds would not.

This had to do with an optimization that Stencil attempts to do during the www build where it will attempt to inline the JS code for the main entry point into the index.html that it produces.

As part of doing this it needs to rewrite some relative paths for any module specifiers that are part of that entry point, since the HTML file is found at www/index.html while the JS entry point will be typically found at www/build/project-name.js. Thus if that entry point has an import like import foo from './bar.js' that will need to be rewritten to import foo from '/build/bar.js' in order for the import to resolve once its inlined into the HTML.

This inlining optimization was previously broken because the path rewrite code did not account for the presence of more than one instance of a given module specifier in the soon-to-be-inlined code. However, by default we have both import and export declarations in that code, so the inlined code would end up non-functional.

To give an example, the problematic excerpt of the inlined code would look typically like this:

import {
  p as e,
  b as t
} from "/build/p-71aeae62.js";
export {
  s as setNonce
}
from "./p-71aeae62.js";

so the first import declaration is being correctly rewritten to begin with /build/ but the export declaration is not. The code should look like this:

import {
  p as e,
  b as t
} from "/build/p-71aeae62.js";
export {
  s as setNonce
}
from "/build/p-71aeae62.js";

This commit fixes the issue by transforming all instances of a given module specifier in the text, and additionally adds a regression test that tests for this specific case.

What is the current behavior?

You can reproduce the issue by doing the following:

  1. Create a new stencil project:
    cd /tmp
    npm init stencil@latest component test-www-double-build
    cd test-www-double-build
    npm i
  2. Edit the stencil.config.ts to only do the www build (just for simplicity)
  3. build the project:
    npx stencil build
  4. Observe that build/index.html. It should look something like this:
    <!doctype html>
    <html dir="ltr" lang="en">
       <head>
          <meta charset="utf-8">
          <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=5.0">
          <title>Stencil Component Starter</title>
          <script type="module" src="/build/test-dev-build-reload.esm.js" data-stencil data-resources-url="/build/" data-stencil-namespace="test-dev-build-reload"></script> <script nomodule="" src="/build/test-dev-build-reload.js" data-stencil>. 
          </script> 
       </head>
       <body>
          <my-component first="Stencil" last="'Don't call me a framework' JS"></my-component>
       </body>
    </html>
  5. Try this build output in a browser. The easiest way is to use a simple webserver to serve the www directory locally, for instance:
    npx http-server www
    This should work with no errors in the console and the <my-component> tag should work properly.
  6. Rebuild the project without deleting or modifying anything in www/ and check out build/index.html again. It should now have inlined JS in the <script> tag, like so:
    <!doctype html>
    <html dir="ltr" lang="en">
       <head>
          <meta charset="utf-8">
          <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=5.0">
          <title>Stencil Component Starter</title>
          <script type="module" data-stencil data-resources-url="/build/" data-stencil-namespace="test-dev-build-reload">
          import{p as e,b as t}from"/build/p-71aeae62.js";export{s as setNonce}from"./p-71aeae62.js";const o=()=>{const s=import.meta.url;const t={};if(s!==""){t.resourcesUrl=new URL(".",s).href}return e(t)};o().then((e=>t([["p-a9942e82",[[1,"my-component",{first:[1],middle:[1],last:[1]}]]]],e)));
             //# sourceMappingURL=test-dev-build-reload.esm.js.map
          </script> <script nomodule="" src="/build/test-dev-build-reload.js" data-stencil></script> 
       </head>
       <body>
          <my-component first="Stencil" last="'Don't call me a framework' JS"></my-component>
       </body>
    </html>
  7. Now try out this build output in the browser (as above). It should fail because the ./p-71aeae62.js module specifier can't be resolved (it should be /build/p-71aeae62.js instead).

What is the new behavior?

If you build and install this in the same project as above you should be able to rebuild without producing a non-working build.

Does this introduce a breaking change?

  • Yes
  • No

Testing

See above. I also expanded the unit tests for this bit of code a bit.

@github-actions
Copy link
Contributor

github-actions bot commented Oct 25, 2023

--strictNullChecks error report

Typechecking with --strictNullChecks resulted in 1393 errors on this branch.

That's the same number of errors on main, so at least we're not creating new ones!

reports and statistics

Our most error-prone files
Path Error Count
src/dev-server/index.ts 37
src/mock-doc/serialize-node.ts 36
src/dev-server/server-process.ts 32
src/compiler/build/build-stats.ts 27
src/compiler/output-targets/dist-lazy/generate-lazy-module.ts 25
src/compiler/style/test/optimize-css.spec.ts 23
src/testing/puppeteer/puppeteer-element.ts 23
src/compiler/prerender/prerender-main.ts 22
src/runtime/vdom/vdom-render.ts 20
src/runtime/client-hydrate.ts 19
src/screenshot/connector-base.ts 19
src/compiler/config/test/validate-paths.spec.ts 16
src/dev-server/request-handler.ts 15
src/compiler/prerender/prerender-optimize.ts 14
src/compiler/sys/stencil-sys.ts 14
src/compiler/transpile/transpile-module.ts 14
src/runtime/vdom/vdom-annotations.ts 14
src/sys/node/node-sys.ts 14
src/compiler/build/build-finish.ts 13
src/compiler/prerender/prerender-queue.ts 13
Our most common errors
Typescript Error Code Count
TS2345 418
TS2322 398
TS18048 310
TS18047 100
TS2722 38
TS2532 34
TS2531 23
TS2454 14
TS2352 13
TS2769 10
TS2790 10
TS2538 8
TS2344 5
TS2416 4
TS2493 3
TS18046 2
TS2684 1
TS2488 1
TS2430 1

Unused exports report

There are 12 unused exports on this PR. That's the same number of errors on main, so at least we're not creating new ones!

Unused exports
File Line Identifier
src/runtime/bootstrap-lazy.ts 21 setNonce
src/screenshot/screenshot-fs.ts 18 readScreenshotData
src/testing/testing-utils.ts 198 withSilentWarn
src/utils/index.ts 145 CUSTOM
src/compiler/app-core/app-data.ts 25 BUILD
src/compiler/app-core/app-data.ts 115 Env
src/compiler/app-core/app-data.ts 117 NAMESPACE
src/compiler/fs-watch/fs-watch-rebuild.ts 123 updateCacheFromRebuild
src/compiler/types/validate-primary-package-output-target.ts 62 satisfies
src/compiler/types/validate-primary-package-output-target.ts 62 Record
src/testing/puppeteer/puppeteer-declarations.ts 485 WaitForEventOptions
src/compiler/sys/fetch/write-fetch-success.ts 7 writeFetchSuccessSync

Copy link
Contributor Author

@alicewriteswrongs alicewriteswrongs left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

some notes

Comment on lines -60 to +89
const orgImportPaths: string[] = [];
const tsSourceFile = ts.createSourceFile('module.ts', code, ts.ScriptTarget.Latest);
ts.transform(tsSourceFile, [readImportPaths(orgImportPaths)]);
const orgModulePaths = readModulePaths(code);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made a change here because I don't think it's actually necessary to use ts.transform here, we were doing so purely to gather information about the source code, not to actually run a transformer and then emit code from it. So instead we can just create a ts.SourceFile and then iterate through the statements on it

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed - I can't think of/see a reason to use transform here 🤔

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about doing the lean-more-into-TypeScript refactor to actually emit the code but I think that's a lot more overhead actually

Comment on lines +153 to +155
function isImportOrExportDecl(stmt: ts.Statement): stmt is ts.ImportDeclaration | ts.ExportDeclaration {
return ts.isExportDeclaration(stmt) || ts.isImportDeclaration(stmt);
}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is I think necessary because I don't think TS is smart enough (yet!) to know that ts.isExportDeclaration(foo) || ts.isImportDeclaration(foo) evaluating to true means it must be one of those two types - instead when I inlined the usage of these two functions it was inferring a type of ts.Statement :/

Comment on lines +60 to +64
it('should update both an import and an export statement', () => {
const input = `import './p-010101.js'; export { foo } from './p-010101.js'`;
const o = updateImportPaths(input, newAbsDir);
expect(o.code).toBe(`import '/build/p-010101.js'; export { foo } from '/build/p-010101.js'`);
});
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a regression test for the bug, I confirmed that it fails on main

Copy link
Member

@tanner-reits tanner-reits left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One possible change, but not a blocker

Comment on lines +94 to +95
code = code.replace(new RegExp(`"${orgImportPath}"`, 'g'), `"${newPath}"`);
code = code.replace(new RegExp(`'${orgImportPath}'`, 'g'), `'${newPath}'`);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to keep the same quotation style that the file originally had? Otherwise we could instantiate a single regex to match the import with either quotation style

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah I suppose we don't - I kept the same quotation-style-preserving logic that was already present, but I really just did so because that was the precedent and not for any real reason

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I don't think we need it, but I'm not opposed to keeping it either. The only thing I can think if is that different quote styles can be used to allow another quote style to be used in a string:

const foo = "We are 'the' champions";

which I don't think would be allowed in module paths (otherwise that'd be very confusing for users!)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok maybe out of an abundance of caution I'll just leave it as-is for now

Copy link
Member

@rwaskiewicz rwaskiewicz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM - I had one tiny ask that's not blocking

* @param code the code to transform
* @returns a list of the module specifiers present in the code
*/
function readModulePaths(code: string) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we throw a return type on this function please?

Comment on lines -60 to +89
const orgImportPaths: string[] = [];
const tsSourceFile = ts.createSourceFile('module.ts', code, ts.ScriptTarget.Latest);
ts.transform(tsSourceFile, [readImportPaths(orgImportPaths)]);
const orgModulePaths = readModulePaths(code);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed - I can't think of/see a reason to use transform here 🤔

Comment on lines +94 to +95
code = code.replace(new RegExp(`"${orgImportPath}"`, 'g'), `"${newPath}"`);
code = code.replace(new RegExp(`'${orgImportPath}'`, 'g'), `'${newPath}'`);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I don't think we need it, but I'm not opposed to keeping it either. The only thing I can think if is that different quote styles can be used to allow another quote style to be used in a string:

const foo = "We are 'the' champions";

which I don't think would be allowed in module paths (otherwise that'd be very confusing for users!)

This fixes a bug where subsequent `www` builds would not always produce
working output. In particular, prior to this change a 'clean' `www`
build (i.e one done after deleting all previous output) would produce a
working build but subsequent builds would not.

This had to do with an optimization that Stencil attempts to do during
the `www` build where it will attempt to inline the JS code for the main
entry point into the `index.html` that it produces.

As part of doing this it needs to rewrite some relative paths for any
module specifiers that are part of that entry point, since the HTML file
is found at `www/index.html` while the JS entry point will be typically
found at `www/build/project-name.js`. Thus if that entry point has an
import like `import foo from './bar.js'` that will need to be rewritten
to `import foo from '/build/bar.js'` in order for the import to resolve
once its inlined into the HTML.

This inlining optimization was previously broken because the path
rewrite code did not account for the presence of more than one instance
of a given module specifier in the soon-to-be-inlined code. However, by
default we have both `import` and `export` declarations in that code, so
the inlined code would end up non-functional.

To give an example, the problematic excerpt of the inlined code would
look typically like this:

```js
import {
  p as e,
  b as t
} from "/build/p-71aeae62.js";
export {
  s as setNonce
}
from "./p-71aeae62.js";
```

so the first `import` declaration is being correctly rewritten to begin
with `/build/` but the `export` declaration is not. The code should look
like this:

```js
import {
  p as e,
  b as t
} from "/build/p-71aeae62.js";
export {
  s as setNonce
}
from "/build/p-71aeae62.js";
```

This commit fixes the issue by transforming all instances of a given
module specifier in the text, and additionally adds a regression test
that tests for this specific case.
@alicewriteswrongs alicewriteswrongs added this pull request to the merge queue Oct 27, 2023
Merged via the queue into main with commit f113b05 Oct 27, 2023
119 of 120 checks passed
@alicewriteswrongs alicewriteswrongs deleted the ap/www-rebuild-huh branch October 27, 2023 15:18
@rwaskiewicz rwaskiewicz added the Resolution: Refine This PR is marked for Jira refinement. We're not working on it - we're talking it through. label Oct 30, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Resolution: Refine This PR is marked for Jira refinement. We're not working on it - we're talking it through.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants