Integrate Project model into loader and decompose into composable stages#7023
Integrate Project model into loader and decompose into composable stages#7023ryancbahan merged 1 commit intomainfrom
Conversation
This stack of pull requests is managed by Graphite. Learn more about stacking. |
Coverage report
Test suite run success3939 tests passing in 1514 suites. Report generated by 🧪jest coverage report action from fe0b46f |
6c3198a to
c7fcd8b
Compare
d7d2097 to
ff9497f
Compare
c7fcd8b to
d60ce08
Compare
288c896 to
b17ef80
Compare
|
We detected some changes at Caution DO NOT create changesets for features which you do not wish to be included in the public changelog of the next CLI release. |
b17ef80 to
57e86f9
Compare
d60ce08 to
2edfd38
Compare
18f3516 to
839512e
Compare
509aacf to
8964add
Compare
839512e to
87728e2
Compare
Differences in type declarationsWe detected differences in the type declarations generated by Typescript for this branch compared to the baseline ('main' branch). Please, review them to ensure they are backward-compatible. Here are some important things to keep in mind:
New type declarationsWe found no new type declarations in this PR Existing type declarationspackages/cli-kit/dist/public/node/themes/api.d.ts@@ -5,7 +5,6 @@ export type ThemeParams = Partial<Pick<Theme, 'name' | 'role' | 'processing' | '
export type AssetParams = Pick<ThemeAsset, 'key'> & Partial<Pick<ThemeAsset, 'value' | 'attachment'>>;
export declare function fetchTheme(id: number, session: AdminSession): Promise<Theme | undefined>;
export declare function fetchThemes(session: AdminSession): Promise<Theme[]>;
-export declare function findDevelopmentThemeByName(name: string, session: AdminSession): Promise<Theme | undefined>;
export declare function themeCreate(params: ThemeParams, session: AdminSession): Promise<Theme | undefined>;
export declare function fetchThemeAssets(id: number, filenames: Key[], session: AdminSession): Promise<ThemeAsset[]>;
export declare function deleteThemeAssets(id: number, filenames: Key[], session: AdminSession): Promise<Result[]>;
packages/cli-kit/dist/public/node/themes/theme-manager.d.ts@@ -8,8 +8,8 @@ export declare abstract class ThemeManager {
protected abstract removeTheme(): void;
protected abstract context: string;
constructor(adminSession: AdminSession);
- findOrCreate(name?: string, role?: Role): Promise<Theme>;
- fetch(name?: string, role?: Role): Promise<Theme | undefined>;
+ findOrCreate(): Promise<Theme>;
+ fetch(): Promise<Theme | undefined>;
generateThemeName(context: string): string;
create(themeRole?: Role, themeName?: string): Promise<Theme>;
}
\ No newline at end of file
|
87728e2 to
490c047
Compare
8964add to
2929234
Compare
490c047 to
e775694
Compare
isaacroldan
left a comment
There was a problem hiding this comment.
Review assisted by pair-review
isaacroldan
left a comment
There was a problem hiding this comment.
The reload behaviour has changed, let's fix that first 🙏
I think some extensive tophat of app dev is necessary before merging, even it appears to work, we need to check the manifest.json and the bundle uploaded to ensure it includes everything
|
Pulling context from Slack -- we discovered the reload issue with manifest writes is already on main, not introduced in this pr. I've added more tests here and will tophat thoroughly + document. |
| vi.useFakeTimers() | ||
| // Prevent real chokidar/fsevents watchers from being created in tests. | ||
| // The hook only needs the AppEventWatcher as an EventEmitter, not real file watching. | ||
| vi.spyOn(AppEventWatcher.prototype, 'start').mockResolvedValue() |
There was a problem hiding this comment.
this was hogging up memory now that tests are sharded
|
Added a bunch more tests around hot reload, file watching, and config switching. Doing a bunch of local tophatting and good so far. |
dmerand
left a comment
There was a problem hiding this comment.
I just have a couple of extra comments.
| const webFiles = activeConfig ? webFilesForConfig(this.project, activeConfig) : this.project.webConfigFiles | ||
| const webTomlPaths = webFiles.map((file) => file.path) | ||
| const webs = await Promise.all( | ||
| webFiles.map((webFile) => loadSingleWeb(webFile.path, this.abortOrReport.bind(this), webFile.content)), |
There was a problem hiding this comment.
extensionFilesForConfig() / webFilesForConfig() intentionally reduce configured globs to simple path prefixes, so patterns like foo/*/extensions over-match. This PR is where that simplification becomes production loader behavior: loadConfigForAppCreation(), loadWebs(), and createExtensionInstances() now route through those helpers instead of the old direct glob discovery. The new integration tests cover rescans and reloads, but they do not protect nontrivial glob shapes. Can we either preserve true glob semantics here or add regression coverage for complex extension_directories and web_directories patterns before merging?
There was a problem hiding this comment.
I'll update to preserve original behavior and add test.
| } | ||
| ]'`) | ||
| vi.mocked(loadAppConfiguration).mockRejectedValue(error) | ||
| vi.mocked(getAppConfigurationContext).mockRejectedValue(error) |
There was a problem hiding this comment.
This test now only proves that discovery errors propagate out of use(). If the intended contract is still "don’t cache a config unless it is valid enough to be used as the active app config," then I think we need at least one fixture-based test with a parseable-but-schema-invalid TOML and an assertion that the cached preference is not updated.
If the new contract is intentionally "syntactically valid + has client_id is enough for use()," then I’d suggest updating the test name/comments to make that boundary explicit, because the old test shape implied a stronger guarantee.
There was a problem hiding this comment.
I'll make the test more explicit that syntactic validation is the aim here.
| const {activeConfig} = await getAppConfigurationContext(directory, configFileName) | ||
| setCurrentConfigPreference(activeConfig.file.content, {configFileName, directory}) |
There was a problem hiding this comment.
I see the intent here is to make app config use only switch the active TOML and defer full schema validation to later commands. I’m still worried about the UX regression relative to the old path: this command used to reject configs that were parseable TOML but no longer valid app config, whereas now it can cache them as the active selection as long as client_id is present. That shifts the failure away from the point where the user made the choice, which makes diagnosis harder.
If that behavior change is intentional, could we call it out explicitly in the command contract/tests? Otherwise I think we should keep a narrow validated load here before writing the cached preference.
There was a problem hiding this comment.
Can you elaborate on which diagnoses are specifically harder? The tests and jsdoc call out the update behavior pretty clearly already.
| await Promise.all( | ||
| fullExtensionDirectories.map(async (dir) => { | ||
| try { | ||
| await mkdir(dir.replace(/\/\*+$/, '')) | ||
| // eslint-disable-next-line no-catch-all/no-catch-all | ||
| } catch { | ||
| // Non-fatal: directory may be unwritable (e.g. test fixtures) | ||
| } | ||
| }), | ||
| ) |
There was a problem hiding this comment.
This makes sense to do, but I don't think it should be the file-watcher responsibility :thinking_face:
Maybe something to do at the start of dev, before the dev session starts?
There was a problem hiding this comment.
I'm not sure I agree -- the file watcher is what breaks if chokidar can't resolve dirs properly.
in any case, is there any way to decouple this feedback to unblock this pr? I think there are improvements we can continue to work on. This was addressing a pre-existing bug and I'd love to see us ship the project model if we agree it's the right abstraction.
There was a problem hiding this comment.
We can problably do this in a separate PR yeah. You are right this is fixing a pre-existing bug and is not related to the main goal of the PR.
|
@dmerand @isaacroldan I'd love to get alignment on your full feedback of what it will take to get this merged. Does the current state of your comments reflect that? I just want to make sure there's clarity in next steps to getting this over the line, if we're in agreement that it's the right abstraction. |
| // Determine the effective client ID | ||
| // Note: activeConfig.file.content is JsonMapType — client_id is untyped. | ||
| // The cast is safe here because activeConfig comes from a linked app (has client_id). | ||
| const configClientId = activeConfig.file.content.client_id as string |
There was a problem hiding this comment.
if we know the activeConfig is linked, why not store the client_id in a way that can be accesed safely?
I know that when building the active config we are already validating this, but if that ever changes this would silently start failing.
A couple options:
- store the client_id as optional directly in the "ActiveConfig"
- Have something like this in ActiveConfig:
{
...
linkedStatus: {isLinked: false} | {isLinked: true, clientId: string}
...
}There was a problem hiding this comment.
To me, this goes against the principle of what we are trying to do. We should be moving away from storing so much state and passing it around, and moving towards more derived state expressly at the callsite that needs it. This is the callsite at which we derive the state.
There was a problem hiding this comment.
I can see that point, but being linked is tied to having a clientId, so I don't think is that bad.
If not, I'd say we'll need to validate here that activeConfig.file.content.client_id is actually a non-empty string, not just casting.
1e6e6de to
5e90c33
Compare
Wires the Project domain model into the existing loading pipeline: - getAppConfigurationState uses Project.load() for filesystem discovery - getAppConfigurationContext returns project + activeConfig + state as independent values (project is never nested inside state) - AppLoader reads from Project's pre-loaded data: extension files, web files, dotenv, hidden config, deps, package manager, workspaces - No duplicate filesystem scanning — Project discovers once, loader reads from it - AppConfigurationState no longer carries project as a field - LoadedAppContextOutput exposes project and activeConfig as top-level fields for commands - All extension/web file discovery filtered to active config's directories via config-selection functions Zero behavioral changes. All 3801 existing tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
a88106a to
fe0b46f
Compare

WHY are these changes introduced?
PR #7022 introduced the
ProjectandActiveConfigdomain models, but nothing consumed them (the loader still ran its own filesystem discovery, constructedAppConfigurationStateas an intermediate, and threaded the entire previousAppInterfacethrough reloads). This PR wires the new models into the loading pipeline and uses that as leverage to decompose the monolithicloadAppinto composable stages with narrow interfaces.WHAT is this pull request doing?
Decomposes the loader into three explicit stages:
getAppConfigurationContext(dir, configName)→{project, activeConfig}— discovery only, no parsing or state constructionloadAppFromContext({project, activeConfig, specifications, …})→AppInterface— validation and assembly, for callers that already hold a ProjectloadApp({directory, configName, …})— thin wrapper composing the two above[Interim state] Replaces
previousAppwith narrowReloadState:Instead of threading the entire
AppLinkedInterfacethrough reloads (just to read 2 fields),reloadAppnow constructs aReloadStatewith onlyextensionDevUUIDs: Map<string, string>andpreviousDevURLs. This is a step change away from broad state passing/mutation, but needs more consideration on "permanent home" as we continue to decompose functionality.Updates all consumers:
linkedAppContextusesactiveConfig.isLinkedandactiveConfig.file.contentdirectly instead of going throughAppConfigurationStatelink()returns{remoteApp, configFileName, configuration}— dropsstatefrom its return typeuse()readsactiveConfig.file.contentinstead of callingloadAppConfigurationloadConfigForAppCreationusesactiveConfigandproject.directorydirectlyRemoves dead code (-400 lines):
AppConfigurationState,AppConfigurationStateBasics,toAppConfigurationState,loadAppConfigurationFromState,loadAppUsingConfigurationState,loadAppConfiguration,getAppConfigurationState,getAppDirectory,loadDotEnv,loadHiddenConfig,findWebConfigPaths,loadWebsForAppCreation,getConfigurationPath(de-exported)How to test your changes?
Measuring impact
Checklist
🤖 Generated with Claude Code