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

Add no-op tier import API #9865

Closed
wants to merge 10 commits into from
Closed

Add no-op tier import API #9865

wants to merge 10 commits into from

Conversation

JakeLane
Copy link
Contributor

↪️ Pull Request

Basic implementation of the API for tiered imports behind a feature flag. Raising this PR early to gain feedback on API and how we should include changes to the runtime code.

@@ -277,7 +278,8 @@ function decorateLegacyGraph(
);
for (let incomingDep of incomingDeps) {
if (
incomingDep.priority === 'lazy' &&
(incomingDep.priority === 'lazy' ||
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 treated as "lazy" for now. I think tier will need some special treatment as we need to act like a 'parallel' priority asset in most cases.

Choose a reason for hiding this comment

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

If this is a noop for now, then it should act the same as a hard require, not a lazy right?

(or are you doing more than nooping?)

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 updated now to use the packager to make the imports sync

import ModulePhase1 from './phase1';
import {deferredLoadComponent} from './utils';
const Phase2 = deferredLoadComponent(
importDeferredForDisplay<typeof import('./phase2')>('./phase2'),
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The type here will need to be enforced with linting. If we add an autofixer, it should be pretty easy to use for devs.

return function WrappedComponent(props) {
useEffect(() => {
return () => {
cleanUp?.();
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Important otherwise we get memory leaks, no keen on this as it looks like a dangerous pattern. While people probably don't want to be using the imports directly often, I'd be happy if someone can suggest a better way of cleaning up automatically

};
}, []);
if (loaded) {
return <Resource.mod.default {...props} />;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We'll need to document that we require a default export. Alternatively we could pass in a key to map to the correct import, but that's messy business I'd like to avoid

}

let code = [];

if (needsEsmLoadPrelude) {
code.push(`let load = require('./helpers/browser/esm-js-loader');`);
}
if (needsTierPrelude) {
code.push(`let tier = require('./helpers/browser/tier-loader');`);
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 code only exists for now in "lazy" priority code (same as doing a dynamic import('x')), this is one of the things that will need to change when we start thinking about "tiered" code possibly existing inline or external.

@@ -36,6 +36,8 @@ import {
} from './utils';
// General regex used to replace imports with the resolved code, references with resolutions,
// and count the number of newlines in the file for source maps.
const REPLACEMENT_RE_TIERED =
/\n|import\s+"([0-9a-f]{16}:.+?)";|(?:\$[0-9a-f]{16}\$exports)|(?:\$[0-9a-f]{16}\$(?:import|importAsync|require|importDeferredForDisplay|importDeferred)\$[0-9a-f]+(?:\$[0-9a-f]+)?)/g;

Choose a reason for hiding this comment

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

Regex replacements aren't ideal. Good if we can shift this to be an AST operation at some point

@@ -175,6 +175,8 @@ pub enum Priority {
Parallel = 1,
/// The dependency should be placed in a separate bundle that is loaded later
Lazy = 2,
/// The dependency should be deferred to a different tier
Tier = 3,

Choose a reason for hiding this comment

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

nit: Call this Deferred

@@ -277,7 +278,8 @@ function decorateLegacyGraph(
);
for (let incomingDep of incomingDeps) {
if (
incomingDep.priority === 'lazy' &&
(incomingDep.priority === 'lazy' ||

Choose a reason for hiding this comment

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

If this is a noop for now, then it should act the same as a hard require, not a lazy right?

(or are you doing more than nooping?)


declare function importDeferred<T extends any | void = void>(
source: T extends void ? ErrorMessage : ModuleRef<T>,
): DeferredImport<T>;

Choose a reason for hiding this comment

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

I recommend that these only provide access to the default exported value, not the module itself.

It's the bundlers responsibility to ensure that devs can't shoot themselves in the foot by doing things like importing the one file both deferred and non-deferred because they're putting multiple values in the one file.


import ModulePhase1 from './phase1';
import {deferredLoadComponent} from './utils';
const Phase2 = deferredLoadComponent(

Choose a reason for hiding this comment

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

Encourage you to not nest these even in examples.

const Phase2Deferred = importDeferredForDisplay<typeof import('./Phase2')>('./Phase2');
const Phase2 = deferredLoadComponent(Phase2Deferred)

If we ever get a syntax spec'd which is compatible with what we're after here, it'll be a top-level declaration. We should also lint so that these APIs are only called on the top-level (avoids memoisation issues and the idea that they're doing something at runtime).

deferredLoadComponent however is a runtime function

Choose a reason for hiding this comment

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

Extra thought about this: by keeping them at the top level we can avoid needing to explore all call expressions and instead only need to tackle the immediate children of the top-level of the module.

Allows us to shortcut the work needed for dependency-extraction (though require will always hold that back)

if (loaded) {
return <Resource.mod.default {...props} />;
} else {
throw new Promise(resolve => {

Choose a reason for hiding this comment

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

This promise needs to be cached, so that re-renders get the same promise instance

interface DeferredImport<T> {
onReady(resource: () => void): () => void;
mod: T | null;
}

Choose a reason for hiding this comment

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

interface DeferredImport<T> {
   onReady(callback: (T) => void): void;
}
  • No need for a cleanup. No bundler cleans up their already loaded JS modules (AFAIK), and if they do they'll set a precedent of how runtime code should drop references.
  • The content is the default value, not the EM module's whole scope. It's so, so, so much easier that way. Means we avoid partial-value caching (remember that caching a promise / callback-able thing means we've caching not only the end value but also the promise / work to get it, else multiple of them will be constructed)
  • No need to expose the module value directly. You need to onReady() to get access to it


export function deferredLoadComponent<T>(
Resource: DeferredImport<T extends {default: any} ? T : never>,
): FC {

Choose a reason for hiding this comment

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

This needs to cache (using a WeakMap) based key'd by the DeferredImport, for both re-renders but also so that multiple components both rendering the same deferred component will be the same thing

@@ -49,6 +49,16 @@ pub enum DependencyKind {
/// import('./dependency').then(({x}) => {/* ... */});
/// ```
DynamicImport,
/// Corresponds to a for display (tier) import statement
/// ```skip
/// const {x} = importDeferredForDisplay('./dependency');

Choose a reason for hiding this comment

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

Suggested change
/// const {x} = importDeferredForDisplay('./dependency');
/// const DependencyDeferred = importDeferredForDisplay('./dependency');

Can't destructure this, it's a class instance.

DeferredForDisplayTierImport,
/// Corresponds to a deferred (tier) import statement
/// ```skip
/// const {x} = importDeferred('./dependency');

Choose a reason for hiding this comment

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

Can't destructure this, it's a class instance.

@@ -168,6 +168,34 @@ pub fn match_require(node: &ast::Expr, unresolved_mark: Mark, ignore_mark: Mark)
}
}

/// This matches an expression like `importDeferredForDisplay('id')` or `importDeferred('id')` and returns the dependency id.
Copy link
Member

Choose a reason for hiding this comment

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

Can you use import attributes for this?

import('id', {with: {tier: 'display'}})

This syntax is now supported by TS and other tooling as well so should be safe to use. The advantage over individual functions is that the priorities aren't hard coded into the transformer, and no special TS declarations for these new non-standard global functions would be needed. Internally, you'd also not need any special dependency types or changes to core data structures, and could just use the import attributes stored on the dependency for the metadata.

Copy link
Contributor Author

@JakeLane JakeLane Jul 24, 2024

Choose a reason for hiding this comment

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

I evaluated this earlier because it would definitely be easier to ship. Unfortunately we're unable to use it yet as there's no way to safely type it (microsoft/TypeScript#46135). I also saw some issues pop up in Jira with our tooling crashing out which made it more time consuming to implement (SWC wasn't happy, nor was Jest).

I'm keen to migrate over though when the type support is added

Copy link
Member

Choose a reason for hiding this comment

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

Maybe I misunderstood the purpose of this feature, but why does the type of the returned module need to be different from a normal import? I assumed it was just about prioritizing the load order.

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'll give a quick overview for the motivation and approach for the project:

Tiers are a concept that allows us to reason about code in different stages of the page load so that developers can have full control over when the user is loading and executing their code. Web vitals give us guidance on what stages we need to consider for user experience, and we can have corresponding tiers.

First is the “loading” tier, where we need to make clear to the users that we’re working on loading their content. We already achieved this pattern with “skeletons”, but we can make this much more efficient by making a specific tier for this and showing the loading state as early as possible. This corresponds to sync imports.

Second is the “visually complete” tier, where the focus is on ensuring users can see everything they need and start interacting with the experience. This tier can be considered SSR returning most of the page as well as any React code required to enable basic interactivity in the page, such that users can make interactions that are actioned when the required code has loaded. This corresponds to importDeferredForDisplay.

Third is the “fully interactive” state, where users can interact fully with the experience without waiting for deferred content to appear. Less critical code, such as logging and background tasks, should be executed in this tier. Importantly, as the visually complete tier has already happened, we should not trigger a paint. We should expect no visual changes during this tier and a Cumulative Layout Shift of ~0. This corresponds to importDeferred.

These new imports need to return a different type so we can treat them differently at runtime, especially for using stuff like suspense (like in the example). Stuff like react-loosely-lazy is what we used before, but this isn't as efficient nor bundler aware. Each tier we want to have a stage of "blocked" and "unblocked" so that each tier cannot interfere with another, which happens with react-loosely-lazy. That's important because in our current solution, we end up with microtasks from critical code being run after non-critical code (such as stuff that doesn't paint or just stuff like analytics).

Copy link
Member

Choose a reason for hiding this comment

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

I'm still not sure why that would require a special type. Couldn't the priority queue be internal to the runtime and not exposed to user 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.

The main issue we have here is we want sync loading if the module is available and promise loading if it's not (like https://relay.dev/docs/glossary/#observable). The APIs available to us right now don't allow this, it's one or the other (import x from 'y' or x = import('y')). Happy to chat about this in more depth in the core catch up

Copy link
Member

Choose a reason for hiding this comment

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

I believe React supports this out of the box using some extensions to the Promise object, namely status, value, and reason. See the RFC for use() here: https://github.com/acdlite/rfcs/blob/first-class-promises/text/0000-first-class-support-for-promises.md#reading-the-result-of-a-promise-that-was-read-previously.

Maybe you can use something similar? It would be really nice to avoid adding new APIs.

Copy link
Contributor

Choose a reason for hiding this comment

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

There are exhaustive tests on this file, could you update them?

Copy link
Contributor

Choose a reason for hiding this comment

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

Please update the tests with your changes

use ast::*;

match node {
Expr::Call(call) => match &call.callee {
Copy link
Contributor

Choose a reason for hiding this comment

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

you can collapse all of this onto just one expression I think

@JakeLane JakeLane changed the title WIP DO NOT REVIEW - Add no-op tier import API Add no-op tier import API Aug 5, 2024
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.

4 participants