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

Tram-One, now with Types! Typescript! v11.0.0 #175

Merged
merged 28 commits into from
Nov 10, 2021
Merged

Tram-One, now with Types! Typescript! v11.0.0 #175

merged 28 commits into from
Nov 10, 2021

Conversation

JRJurman
Copy link
Member

@JRJurman JRJurman commented Oct 21, 2021

Summary

In an effort to get the advantages of type safety, this PR migrates the source code (and most of the tests) to Typescript.

This gives the project more type safety, and allows our consumers to build projects with type safety (if they are using Typescript). If they aren't using Typescript they still will get proper type annotations when working with compatible editors (such as Visual Studio Code).

Changes

All the changes listed below are detailed in the comments, treat these as a high level breakdown.

Bump Major - v11.0.0

In reality, this might not be breaking for JS projects, but there are a significant number of changes (including the removal of some safety checks for JS and addition of checks for TS). Anyone using Typescript would start getting compile time errors / warnings, so for that reason alone really it should be considered a breaking change.

Main Changes

  • Remove manual type checking in the project
  • Remove manual JS Doc annotations
  • types.ts file for typescript type definitions

Auxiliary Changes

  • Using Prettier instead XO
  • Expand ...args for more clear parameter definitions
  • Fix file JSDoc blocks to not be associated with a variable
  • Fixed Document TreeWalker to properly filter elements
  • Removed server-side rendering support

Testing Changes

Artifacts: typescript...typescript-artifacts

Note about the artifacts For some reason, CircleCI slowed down between the last master run and this changeset. This was confirmed with a dummy PR which had similar slowdowns (with no changes).

Checklist

  • PR Summary
  • Update tsconfig to be strict
  • Update DocStrings
  • Verify DocStrings
  • Update Build tooling for Modules
  • Manual Verification in a JS Project
  • Manual Verification in a TS Project
  • Investigate slower speeds (run dummy PR against master)
  • Update top-level README
  • PR Comments (Initial Pass)
  • PR Comments (Final Pass)
  • Update Linter (now including prettier)
  • Tests
  • Version Bump
  • Code Coverage Review

Post Merge Checklist

  • Update Website

@JRJurman JRJurman marked this pull request as draft October 21, 2021 03:36
@@ -31,7 +31,7 @@ jobs:
fingerprints:
- "64:3e:d4:d8:4b:95:68:79:d3:3b:ab:b1:5c:fa:2d:3d"

- run: npm install
- run: npm ci
Copy link
Member Author

Choose a reason for hiding this comment

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

I think this project originally predated npm ci, but now that it exists, we should be using that, and not regenerating the lock file on every PR.

@JRJurman JRJurman changed the title Initial Pass at migrating to Typescript Tram-One, now with Types! Typescript! v11.0.0 Oct 22, 2021
@JRJurman
Copy link
Member Author

A lot of the runtime checks can actually be removed now that we verify the return type of Tram-One Components
image

Comment on lines -18 to -20
# SSL certificates
.ssl

Copy link
Member Author

Choose a reason for hiding this comment

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

This was most certainly an artifact of when we had the website hosted from this repo, since that is no longer a thing, we can remove this

.prettierrc.js Outdated
Comment on lines 1 to 5
module.exports = {
useTabs: true,
singleQuote: true,
printWidth: 120,
};
Copy link
Member Author

Choose a reason for hiding this comment

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

Using prettier instead of xo.

The prettier config is more known to work with typescript, and xo was breaking too much.

Eventually I'd like to rip out the eslint config for tram-one projects as it is today. It really should be something that validates tram-one things (e.g., I would love to lint template spacing, and hook placement). Right now, it's just silly opinions, and that doesn't really help anyone.

Copy link
Member Author

Choose a reason for hiding this comment

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

This includes using semicolons (which sadly blows the number of lines changed for the project super high). ASI has burned me enough times (read, more than 0 times), and I think with the new Typescript syntax, it's easier if we are on the same standard as everyone else.

Comment on lines +90 to +98
it('should process effects on the root node', async () => {
// start the app
const { container } = startApp();

// verify that it (eventually) has loaded: true
await waitFor(() => {
expect(getByText(container, 'Root Loaded: true')).toBeVisible();
});
});
Copy link
Member Author

Choose a reason for hiding this comment

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

New test, we didn't really have something to validate this, but we did have code in the project that would have caused this to fail and nothing else.

* component to test url parameters
*/
export default () => {
const mirrorable = useGlobalStore('mirrorable-input') as InputObject;
Copy link
Member Author

Choose a reason for hiding this comment

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

The type isn't actually required if you pass in a default value. The fact that this option exists is cool, and possible because of function overloading.

Comment on lines -14 to -24
it('should warn if a component does not return anything', () => {
expect(() => startBrokenApp('empty')()).toThrowError('Tram-One: expected component to return an Element, instead got undefined. Verify the component is a function that returns DOM.')
})

it('should warn if a component does not return an element', () => {
expect(() => startBrokenApp('non-dom')()).toThrowError('Tram-One: expected component to return an Element, instead got string. Verify the component is a function that returns DOM.')
})

it('should warn if a component returns an array', () => {
expect(() => startBrokenApp('array')()).toThrowError('Tram-One: Sorry, Tram-One does not currently support array returns. Wrap components in an element before returning.')
})
Copy link
Member Author

Choose a reason for hiding this comment

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

aforementioned removed tests

Copy link
Member Author

Choose a reason for hiding this comment

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

the other ones in this file still exist because they are more runtime dependent. Technically we should be able to catch the "warn if a hook is called outside of a component context", but that requires building lint or typescript rules around that. Nothing obvious or trivial was available online on how to do this.

package.json Outdated
@@ -1,6 +1,6 @@
{
"name": "tram-one",
"version": "10.1.11",
"version": "11.0.0",
Copy link
Member Author

Choose a reason for hiding this comment

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

Major bump. In reality, this might not be breaking for JS projects, but anyone using Typescript would start getting compile time errors / warnings, so for that reason, it should be breaking.

Comment on lines 22 to 29
/**
* Function to determine (or create) the element that we will mount our tram-one app onto
* @param target either a CSS selector, or HTMLElement to attach the component to.
* This elememnt should be initially empty.
*
* @returns the container, now with a div that tram-one can manage
*/
export default (target: ElementOrSelector): HTMLElement => {
Copy link
Member Author

Choose a reason for hiding this comment

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

DocStrings no longer have types in them. These should be inherited from the Types in the method definition / implementation.

src/dom.ts Outdated
Comment on lines 32 to 49
const hookedTagFunction = (props: Props, children: Children) => {
// push a new branch onto the working key so any values that need to be unique among components
// but consistent across renders can be read
const stringifiedProps = JSON.stringify(props);
const newBranch = `${tagName}[${stringifiedProps}]`;
pushWorkingKeyBranch(TRAM_HOOK_KEY, newBranch);

// increment branch so that we have a unique value (in case we are rendering a list of components)
incrementWorkingKeyBranch(TRAM_HOOK_KEY);
const uniqueBranch = copyWorkingKey(TRAM_HOOK_KEY);

// create a tag function that has the args passed in
const populatedTagFunction = () => {
// reset working key so we have the correct place when starting a new component
restoreWorkingKey(TRAM_HOOK_KEY, uniqueBranch);

return tagFunction(props, children);
};
Copy link
Member Author

Choose a reason for hiding this comment

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

Previously this function used (...args) instead of (props, children) in method definitions. Potentially at the time I didn't know what this method definition would be, or wanted it to be more flexible, or maybe it was really really early on when component methods had a ton of things in them. Either way, it looks like this now, and should be easier to understand and more explicit.

@@ -1,33 +1,27 @@
/**
/*
Copy link
Member Author

Choose a reason for hiding this comment

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

In some places I had inadvertently (or lazily) use /** which makes blocks more readily, but gets consumed as docstrings for the next variable, which was not the intention.

Comment on lines +14 to +16
// this sadly needs to be wrapped in some element so we can process effects
// otherwise the root node will not have effects applied on it
const renderedApp = html`<div><app /></div>`;
Copy link
Member Author

Choose a reason for hiding this comment

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

This is the code that was previously untested. Ideally this wouldn't exist, and potentially we should annotate it / change it to be more clearly intended for tram-one (with a different tag, or an id). However, any more additions to this would push us away from just getting rid of it altogether.

Copy link
Member Author

@JRJurman JRJurman Oct 29, 2021

Choose a reason for hiding this comment

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

We should look to change this into a Document Fragment 🤔
Never mind, probably runs into the same issues.

Comment on lines 77 to 78
const nodeFilterForTramOneComponent = { acceptNode: isTramOneComponent };
const componentWalker = document.createTreeWalker(node, NodeFilter.SHOW_ELEMENT, nodeFilterForTramOneComponent);
Copy link
Member Author

Choose a reason for hiding this comment

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

Funny story, previously, this was implemented wrong. createTreeWalker takes in an object with a key acceptNode. Typescript caught this, I'm not sure how this was working before, and sadly it doesn't seem to have improved performance. Either way, it's correct now 👍

window['tram-space'] = {};
};

export const buildNamespace = <NamespaceStore>(constructor: () => NamespaceStore) => {
Copy link
Member Author

Choose a reason for hiding this comment

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

In order to have the get set and setup functions all produce the same Type, we now have one generic function whose type is inferred by the constructor that is used.

*
* It has a similar interface to React's useState
*/
export default <Store extends StoreObject>(key?: string, value?: Store): Store => {
Copy link
Member Author

Choose a reason for hiding this comment

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

Previously this function verified the type of the string and value, now that can be verified with Typescript! JS users will still get annotations in Typescript capable editors.

Comment on lines +71 to +73
// we'll assume that the element is an HTMLInputElement, in reality other kinds of elements will be caught here,
// but that's fine, since they have null as selection attributes, and setting them to null is fine
const activeElement = document.activeElement as HTMLInputElement;
Copy link
Member Author

Choose a reason for hiding this comment

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

Pretty much what it says in the comment. This is because Typescript (and somewhat inaccurately so) complains that "Element" doesn't have the selection attributes (e.g. selectionStart), in reality, these will be null, which I guess for Typescript is just as bad, but for us, that is fine.

*
* These are later processed by the mutation-observer, and cleaned up when the node is removed by the mutation-observer.
*/
export default (tagFunction: TramOneComponent) => {
Copy link
Member Author

@JRJurman JRJurman Oct 24, 2021

Choose a reason for hiding this comment

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

Previously this function verified that the tagFunction returned an Element, but now we get that with Typescript!

Comment on lines 5 to 35
/**
* @name useGlobalObservable
* @link https://tram-one.io/#use-global-observable
* @description
* Hook that stores global state and makes it accessible in the entire app.
*
* If the subfield of an object, or element of an array is updated
* it will cause only the components that are dependent on that value to update.
*
* @param key a unique string to write and read the global value
* @param defaultValue the default value to start the store at
*
* @returns the store to interact with.
*/
function useGlobalStore<Store extends StoreObject>(key: string, defaultValue: Store): Store;
/**
* @name useGlobalObservable
* @link https://tram-one.io/#use-global-observable
* @description
* Hook that stores global state and makes it accessible in the entire app.
*
* If the subfield of an object, or element of an array is updated
* it will cause only the components that are dependent on that value to update.
*
* @param key a unique string to write and read the global value
*
* @returns the store to interact with.
*/
function useGlobalStore(key: string): unknown;
/** Implementation of the two function definitions */
function useGlobalStore(key: string, defaultValue?): any {
Copy link
Member Author

Choose a reason for hiding this comment

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

Function overloading! The first two definitions (19 & 33) are the two possible method interfaces, and the third definition (35) is the more generic version that is used for the code implementation. This allows us to have a version of the method that knows the return type (in this case, inferred from the defaultValue). In cases where you don't pass a default value, you have to specifically say what the type is.

Comment on lines +66 to +70
### Is Tram-One for Javascript or Typescript?

Both! While the source code and type checking exist in Typescript, smart editors (such as Visual Studio Code), will make use of
the Typescript annotations regardless of what language you work in!

Copy link
Member Author

Choose a reason for hiding this comment

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

I'd love to make a more general FAQ and Principles section in the Tram-One Website, but in the interim, this will have to do 😅

@JRJurman JRJurman marked this pull request as ready for review October 29, 2021 01:36
@JRJurman
Copy link
Member Author

JRJurman commented Nov 8, 2021

When playing around in a typescript project, people can use the following syntax to verify the component follows the correct interface:

import { registerHtml, TramOneComponent } from 'tram-one'

const html = registerHtml()

const AppHeader: TramOneComponent  = (props, children) => {
	return html`
		<h1 class="app-header">
			${children}
		</h1>
	`
}

export default AppHeader

Comment on lines +1 to +3
// NOTE: This won't work since Tram-One does not (right now) support server-side rendering
// This is on our backlog though, and if it is something that you would like, please vote / comment here:
// https://github.com/Tram-One/tram-one/issues/181
Copy link
Member Author

Choose a reason for hiding this comment

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

Domino was removed, so this runkit example won't work, but I'm keeping it here because one day it hopefully will 😄

/**
* component to test url parameters
*/
const account: TramOneComponent = () => {
Copy link
Member Author

Choose a reason for hiding this comment

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

This is a pattern components can use, to verify that they follow the interface for a "TramOneComponent"

Comment on lines +1 to +3
// file needed for importing package-json
// see https://rollupjs.org/guide/en/#using-untranspiled-config-files
module.exports = require('./package.json');
Copy link
Member Author

Choose a reason for hiding this comment

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

basically what the comments say, this is required for loading the package.json in es modules.

Comment on lines +10 to +16
"type": "module",
"exports": {
".": {
"import": "./dist/tram-one.mjs",
"require": "./dist/tram-one.cjs"
}
},
Copy link
Member Author

Choose a reason for hiding this comment

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

using types and export, this may not be totally consumed correctly until the next version of typescript, but we can future proof it 👍

Comment on lines +58 to +62
// decorate the properties expected on TramOneElements (see node-names.ts)
tagResult[TRAM_TAG] = true;
// we won't decorate TRAM_TAG_REACTION, that needs to be done later when we observe the tag
tagResult[TRAM_TAG_NEW_EFFECTS] = tagResult[TRAM_TAG_NEW_EFFECTS] || [];
tagResult[TRAM_TAG_CLEANUP_EFFECTS] = tagResult[TRAM_TAG_NEW_EFFECTS] || [];
Copy link
Member Author

Choose a reason for hiding this comment

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

decorating all the properties of a tram one tag, so that they are available for sure

src/tram-one.ts Outdated
Comment on lines 7 to 17
export {
ElementOrSelector,
DOMTaggedTemplateFunction,
TramOneComponent,
StoreObject,
CleanupEffect,
Effect,
Props,
Registry,
UrlMatchResults,
} from './types';
Copy link
Member Author

Choose a reason for hiding this comment

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

Exposing these types publicly, so that other Typescript projects can use them directly.

/**
* Type for when we can take a CSS Selector, or an HTML Element (mostly mounting).
*/
export type ElementOrSelector = [string | HTMLElement][0];
Copy link
Member Author

Choose a reason for hiding this comment

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

I'm not 100% sure about this pattern, but it allows consumers to understand what the library is looking for (rather than just saying "ElementOrSelector")

Copy link
Contributor

@ethanjurman ethanjurman left a comment

Choose a reason for hiding this comment

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

Awesome stuff! Cannot wait for typescript magic!!

Copy link
Contributor

@chtinahow chtinahow left a comment

Choose a reason for hiding this comment

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

👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants