diff --git a/CHANGELOG.md b/CHANGELOG.md index bc365070e2..06ec85beee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ All historical references to "CFWheels" in this changelog have been preserved fo - Linux `.deb` / `.rpm` packages double-nested the framework at `/opt/wheels/module/vendor/wheels/wheels/` instead of `/opt/wheels/module/vendor/wheels/`. `wheels-core-VER.zip` carries a top-level `wheels/` directory that `unzip` preserves; the nfpm `type: tree` rule then copied the entire `build/framework/` tree (wrapper and all) into the destination, leaving `Injector.cfc` one level too deep. Every fresh `wheels new` install on Ubuntu/Fedora then crashed on first request with `could not find component or class with name [wheels.Injector]`, cascading into the cryptic `The key [WO] does not exist.` error in `onError`. The brew formula handles this correctly via `(share/"wheels/framework/wheels").install Dir["*"]`; the Linux nfpm configs now pin `src` at `./build/framework/wheels/` to match. Regression spec at `vendor/wheels/tests/specs/cli/LinuxPackageStagingSpec.cfc` (#2773) - `onError` in the generated app template and demo `public/Application.cfc` now guards `application.wo` with `StructKeyExists(application, "wo")` after the recovery try/catch. When `new wheels.Injector(...)` fails during `onApplicationStart` (e.g. a stale `/wheels` mapping under Lucee Express 7), the original error is preserved via a minimal HTML fallback instead of cascading into the cryptic "The key [WO] does not exist" exception that hit "Your First 15 Minutes" tutorial users on fresh installs +- Bare `?reload=true` now auto-detects changes to `app/global/*.cfm` in development and re-evaluates `app/global/functions.cfm` (plus any files it ``s) when any tracked file's mtime has changed, so new helpers added to `app/global/functions.cfm` (or any file it includes) are picked up without the password-gated `applicationStop()` path. Wheels snapshots the include directory on application start and re-evaluates the include via a new `application.wo.$reincludeGlobals()` hook when `$globalIncludesChanged()` detects an added/removed/touched file. Opt out with `set(reloadOnGlobalChange=false)` in `config/settings.cfm` (issue #2792) ---- diff --git a/vendor/wheels/Global.cfc b/vendor/wheels/Global.cfc index 96cb870485..99c009a6b5 100644 --- a/vendor/wheels/Global.cfc +++ b/vendor/wheels/Global.cfc @@ -3849,6 +3849,87 @@ return local.$wheels; } } + /** + * Snapshot mtimes of all .cfm files under the app's global include directory. + * + * Used by the bare `?reload=true` path so a developer adding a helper to + * `app/global/*.cfm` does not have to remember the password-gated full reload + * (issue ##2792). + */ + public struct function $snapshotGlobalIncludes(string directory = ExpandPath("/app/global")) { + var snapshot = {}; + if (!DirectoryExists(arguments.directory)) { + return snapshot; + } + var files = DirectoryList(arguments.directory, true, "query", "*.cfm"); + for (var row in files) { + snapshot[row.directory & "/" & row.name] = row.dateLastModified; + } + return snapshot; + } + + /** + * Compare a prior `$snapshotGlobalIncludes` result against the current + * filesystem state and return true if any tracked .cfm file was added, + * removed, or modified. + * + * Paired with `$snapshotGlobalIncludes` to drive the bare `?reload=true` + * soft-reload path in development (issue ##2792). + */ + public boolean function $globalIncludesChanged( + required struct snapshot, + string directory = ExpandPath("/app/global") + ) { + var current = $snapshotGlobalIncludes(directory = arguments.directory); + for (var key in current) { + if (!StructKeyExists(arguments.snapshot, key)) { + return true; + } + if (DateCompare(arguments.snapshot[key], current[key]) != 0) { + return true; + } + } + for (var key in arguments.snapshot) { + if (!StructKeyExists(current, key)) { + return true; + } + } + return false; + } + + /** + * Re-evaluate the given global-includes file into `application.wo`'s + * variables/this scope. Invoked from the bare `?reload=true` soft-reload + * when `$globalIncludesChanged` reports drift (issue ##2792). + * + * `include` inside a method body adds function declarations to the + * method's local scope, not the component's outer scope, so we walk + * local for any user-defined functions and copy them onto variables + * and this so they remain callable on `application.wo` across requests. + */ + public void function $reincludeGlobals(string file = "/app/global/functions.cfm") { + include "#arguments.file#"; + // Lucee adds include-declared functions to local; Adobe adds them + // to variables. Walk both and lift any user-defined functions onto + // this (the application.wo facing scope) so callers can invoke them + // across requests. The second loop is unconditional: a snapshot-diff + // guard here would suppress *updates* on Adobe (where the function + // already lives in variables from a prior re-include), leaving this + // bound to the stale version on the second `?reload=true`. Re-lifting + // an existing function is idempotent and the path is development-only. + for (var key in local) { + if (IsCustomFunction(local[key])) { + variables[key] = local[key]; + this[key] = local[key]; + } + } + for (var key in variables) { + if (IsCustomFunction(variables[key])) { + this[key] = variables[key]; + } + } + } + // User-defined global functions include "/app/global/functions.cfm"; diff --git a/vendor/wheels/events/EventMethods.cfc b/vendor/wheels/events/EventMethods.cfc index 1360524187..e5e80a285b 100644 --- a/vendor/wheels/events/EventMethods.cfc +++ b/vendor/wheels/events/EventMethods.cfc @@ -174,6 +174,37 @@ component extends="wheels.Global" implements="wheels.interfaces.events.EventHand request.$wheelsHeaders = GetHTTPRequestData().headers; } + // Soft-reload of app/global/*.cfm when bare ?reload=true is hit in dev + // and any tracked include has a newer mtime. The password-gated + // applicationStop() path already does a full re-init, so we skip the + // check when url.password is present (issue 2792). + // + // The environment guard is intentional: this soft-reload is a + // development-only convenience. Use the password-gated reload for + // non-dev environments — setting reloadOnGlobalChange=true outside + // development is a deliberate no-op. + if ( + StructKeyExists(url, "reload") + && !StructKeyExists(url, "password") + && StructKeyExists(application.wheels, "reloadOnGlobalChange") + && application.wheels.reloadOnGlobalChange + && application.wheels.environment == "development" + && StructKeyExists(application.wheels, "globalIncludesSnapshot") + && application.wo.$globalIncludesChanged(snapshot = application.wheels.globalIncludesSnapshot) + ) { + // Double-checked locking — two concurrent ?reload=true hits would + // otherwise both pass the outer check and race on $reincludeGlobals + // against the same application.wo instance. Lock name is + // per-application so shared Adobe CF servers running multiple + // apps don't serialize on a single global lock. + lock type="exclusive" name="wheels_reload_globals_#application.applicationName#" timeout="5" { + if (application.wo.$globalIncludesChanged(snapshot = application.wheels.globalIncludesSnapshot)) { + application.wo.$reincludeGlobals(); + application.wheels.globalIncludesSnapshot = application.wo.$snapshotGlobalIncludes(); + } + } + } + // Reload the plugins on each request if cachePlugins is set to false. if (!application.wheels.cachePlugins) { $loadPlugins(); diff --git a/vendor/wheels/events/init/orm.cfm b/vendor/wheels/events/init/orm.cfm index 6250011010..269a3036b2 100644 --- a/vendor/wheels/events/init/orm.cfm +++ b/vendor/wheels/events/init/orm.cfm @@ -24,6 +24,10 @@ application.$wheels.suppressRouteBindingWarnings = false; application.$wheels.reloadPassword = ""; application.$wheels.redirectAfterReload = false; + // Soft-reload of app/global/*.cfm on ?reload=true when files change. + // Defaults true in development so adding a helper to app/global is picked + // up without the password-gated applicationStop() path (issue 2792). + application.$wheels.reloadOnGlobalChange = (application.$wheels.environment == "development"); application.$wheels.softDeleteProperty = "deletedAt"; application.$wheels.timeStampOnCreateProperty = "createdAt"; application.$wheels.timeStampOnUpdateProperty = "updatedAt"; diff --git a/vendor/wheels/events/onapplicationstart.cfc b/vendor/wheels/events/onapplicationstart.cfc index c367c9ba8c..5d34d9c563 100644 --- a/vendor/wheels/events/onapplicationstart.cfc +++ b/vendor/wheels/events/onapplicationstart.cfc @@ -364,6 +364,10 @@ component { // Create the dispatcher that will handle all incoming requests. application.$wheels.dispatch = application.wo.$createObjectFromRoot(path = "wheels", fileName = "Dispatch", method = "$init"); + // Snapshot the app/global/*.cfm mtimes so the per-request soft-reload + // check (in $runOnRequestStart) has a baseline to compare against. + application.$wheels.globalIncludesSnapshot = application.wo.$snapshotGlobalIncludes(); + // Assign it all to the application scope in one atomic call. application.wheels = application.$wheels; StructDelete(application, "$wheels"); diff --git a/vendor/wheels/tests/specs/global/reloadGlobalsSpec.cfc b/vendor/wheels/tests/specs/global/reloadGlobalsSpec.cfc new file mode 100644 index 0000000000..898781196e --- /dev/null +++ b/vendor/wheels/tests/specs/global/reloadGlobalsSpec.cfc @@ -0,0 +1,115 @@ +component extends="wheels.WheelsTest" { + + function run() { + + // Shared struct so nested describe / beforeEach / afterEach / it closures + // can read `g` and `baseDir` on Adobe CF 2023/2025. CFML closures cannot + // reach an enclosing function's `local` scope on Adobe CF (CLAUDE.md + // cross-engine invariant ##3); a struct is a reference type, so all + // closures share the same object via `variables.ctx`. + var ctx = { + g: application.wo, + baseDir: ExpandPath("/wheels/tests/_tmp/reloadGlobals") + }; + + describe("Reload — global includes mtime tracking (issue ##2792)", () => { + + beforeEach(() => { + if (DirectoryExists(ctx.baseDir)) { + DirectoryDelete(ctx.baseDir, true); + } + // DirectoryCreate(path, true) is Lucee-only (issue ##2567); + // java.io.File.mkdirs() recurses parents on every engine. + CreateObject("java", "java.io.File").init(ctx.baseDir).mkdirs(); + }); + + afterEach(() => { + if (DirectoryExists(ctx.baseDir)) { + DirectoryDelete(ctx.baseDir, true); + } + }); + + it("$snapshotGlobalIncludes returns a struct keyed by cfm file paths", () => { + FileWrite(ctx.baseDir & "/fixtureA.cfm", "function fxA(){return 1;}"); + FileWrite(ctx.baseDir & "/fixtureB.cfm", "function fxB(){return 2;}"); + var snapshot = ctx.g.$snapshotGlobalIncludes(directory = ctx.baseDir); + expect(snapshot).toBeStruct(); + expect(StructCount(snapshot)).toBe(2); + }); + + it("$snapshotGlobalIncludes returns an empty struct when the directory does not exist", () => { + var missing = ctx.baseDir & "/does-not-exist"; + var snapshot = ctx.g.$snapshotGlobalIncludes(directory = missing); + expect(snapshot).toBeStruct(); + expect(StructCount(snapshot)).toBe(0); + }); + + it("$globalIncludesChanged returns false when no files changed", () => { + FileWrite(ctx.baseDir & "/stable.cfm", "function fxStable(){return 'stable';}"); + var snapshot = ctx.g.$snapshotGlobalIncludes(directory = ctx.baseDir); + expect(ctx.g.$globalIncludesChanged(snapshot = snapshot, directory = ctx.baseDir)).toBeFalse(); + }); + + it("$globalIncludesChanged returns true when a new cfm file appears", () => { + FileWrite(ctx.baseDir & "/one.cfm", "function fxOne(){return 1;}"); + var snapshot = ctx.g.$snapshotGlobalIncludes(directory = ctx.baseDir); + FileWrite(ctx.baseDir & "/two.cfm", "function fxTwo(){return 2;}"); + expect(ctx.g.$globalIncludesChanged(snapshot = snapshot, directory = ctx.baseDir)).toBeTrue(); + }); + + it("$globalIncludesChanged returns true when a tracked cfm file is removed", () => { + FileWrite(ctx.baseDir & "/keep.cfm", "function fxKeep(){return 1;}"); + FileWrite(ctx.baseDir & "/gone.cfm", "function fxGone(){return 2;}"); + var snapshot = ctx.g.$snapshotGlobalIncludes(directory = ctx.baseDir); + FileDelete(ctx.baseDir & "/gone.cfm"); + expect(ctx.g.$globalIncludesChanged(snapshot = snapshot, directory = ctx.baseDir)).toBeTrue(); + }); + + it("$globalIncludesChanged tolerates an empty starting snapshot", () => { + var snapshot = {}; + FileWrite(ctx.baseDir & "/added.cfm", "function fxAdded(){return 1;}"); + expect(ctx.g.$globalIncludesChanged(snapshot = snapshot, directory = ctx.baseDir)).toBeTrue(); + }); + + it("$globalIncludesChanged returns true when a tracked cfm file is modified", () => { + // Exercise the DateCompare != 0 branch — the "developer edited + // an existing helper" path the PR is designed to serve. + // Backdate the snapshot entry rather than sleeping for a fresh + // mtime, so the test is deterministic across filesystems with + // different mtime granularities (ext4 nanosecond vs APFS/HFS+ + // 1-second). + FileWrite(ctx.baseDir & "/modified.cfm", "function fxV1(){return 1;}"); + var snapshot = ctx.g.$snapshotGlobalIncludes(directory = ctx.baseDir); + var key = ListFirst(StructKeyList(snapshot)); + snapshot[key] = DateAdd("s", -60, snapshot[key]); + expect(ctx.g.$globalIncludesChanged(snapshot = snapshot, directory = ctx.baseDir)).toBeTrue(); + }); + + it("$reincludeGlobals re-evaluates the target cfm without throwing", () => { + // CFML's `include` resolves via mappings, not absolute filesystem + // paths — call $reincludeGlobals with the mapping-relative form. + var absPath = ExpandPath("/wheels/tests/_tmp/reloadGlobals/reinclude.cfm"); + FileWrite(absPath, "function fxReinclude(){return 'first';}"); + $assert.notThrows(function() { + application.wo.$reincludeGlobals(file = "/wheels/tests/_tmp/reloadGlobals/reinclude.cfm"); + }); + // The contract: re-including must make the function callable + // on application.wo. Without this assertion, a silent no-op + // on any engine would slip through. + expect(IsDefined("application.wo.fxReinclude")).toBeTrue(); + + // After overwriting the file, re-running the include should also + // succeed — covers the "developer just changed a helper" path + // that the bare ?reload=true workflow targets. Assert the + // *return value* changes so an Adobe-only silent no-op (the + // old version stays bound to `this`) can't slip past CI. + FileWrite(absPath, "function fxReinclude(){return 'second';}"); + $assert.notThrows(function() { + application.wo.$reincludeGlobals(file = "/wheels/tests/_tmp/reloadGlobals/reinclude.cfm"); + }); + expect(application.wo.fxReinclude()).toBe("second"); + }); + + }); + } +} diff --git a/web/sites/guides/src/content/docs/v4-0-0/command-line-tools/wheels-commands/dev-server.mdx b/web/sites/guides/src/content/docs/v4-0-0/command-line-tools/wheels-commands/dev-server.mdx index eb69ce2f24..867f527190 100644 --- a/web/sites/guides/src/content/docs/v4-0-0/command-line-tools/wheels-commands/dev-server.mdx +++ b/web/sites/guides/src/content/docs/v4-0-0/command-line-tools/wheels-commands/dev-server.mdx @@ -114,7 +114,7 @@ wheels reload On success: `Application reloaded successfully.` in green. On failure — most often a password mismatch — it prints `Failed to reload: ` and hints at setting `RELOAD_PASSWORD` in `.env` or `config/settings.cfm`. ## Dev loop example diff --git a/web/sites/guides/src/content/docs/v4-0-0/core-concepts/environments-and-configuration.mdx b/web/sites/guides/src/content/docs/v4-0-0/core-concepts/environments-and-configuration.mdx index d33d664427..21ae15c1c0 100644 --- a/web/sites/guides/src/content/docs/v4-0-0/core-concepts/environments-and-configuration.mdx +++ b/web/sites/guides/src/content/docs/v4-0-0/core-concepts/environments-and-configuration.mdx @@ -51,6 +51,8 @@ set(environment = application.wo.env("WHEELS_ENV", "development")); Settings cascade: framework defaults load first, then `config/settings.cfm`, then `config//settings.cfm`. Later writes win. A value you set in the shared file is the default for every environment; the environment-specific file only needs to name the keys it changes. +One development-only default worth noting: `reloadOnGlobalChange` (defaults `true` in development) makes bare `?reload=true` re-include `app/global/*.cfm` when any tracked file's mtime changes, so new helpers added to `app/global/functions.cfm` are picked up immediately without the password-gated full reload. Opt out with `set(reloadOnGlobalChange=false)` in `config/settings.cfm`. The setting defaults to `false` in all other environments, so there is no `DirectoryList` overhead in production. + ## Environment detection The active environment is set by `config/environment.cfm`, which typically reads `WHEELS_ENV` from the environment variables with a default of `development`. The `wheels` CLI respects the same variable, and `.env` files loaded at boot get merged into the lookup. Override in CI or production by exporting `WHEELS_ENV=production` before the app starts — a deploy script, a Dockerfile `ENV`, or a systemd unit is the usual place. diff --git a/web/sites/guides/src/content/docs/v4-0-1-snapshot/command-line-tools/wheels-commands/dev-server.mdx b/web/sites/guides/src/content/docs/v4-0-1-snapshot/command-line-tools/wheels-commands/dev-server.mdx index fbc84ea7b5..68c3782b64 100644 --- a/web/sites/guides/src/content/docs/v4-0-1-snapshot/command-line-tools/wheels-commands/dev-server.mdx +++ b/web/sites/guides/src/content/docs/v4-0-1-snapshot/command-line-tools/wheels-commands/dev-server.mdx @@ -114,7 +114,7 @@ wheels reload On success: `Application reloaded successfully.` in green. On failure — most often a password mismatch — it prints `Failed to reload: ` and hints at setting `RELOAD_PASSWORD` in `.env` or `config/settings.cfm`. ## Dev loop example diff --git a/web/sites/guides/src/content/docs/v4-0-1-snapshot/core-concepts/environments-and-configuration.mdx b/web/sites/guides/src/content/docs/v4-0-1-snapshot/core-concepts/environments-and-configuration.mdx index 30b704ca4c..9fac39ae9b 100644 --- a/web/sites/guides/src/content/docs/v4-0-1-snapshot/core-concepts/environments-and-configuration.mdx +++ b/web/sites/guides/src/content/docs/v4-0-1-snapshot/core-concepts/environments-and-configuration.mdx @@ -51,6 +51,8 @@ set(environment = application.wo.env("WHEELS_ENV", "development")); Settings cascade: framework defaults load first, then `config/settings.cfm`, then `config//settings.cfm`. Later writes win. A value you set in the shared file is the default for every environment; the environment-specific file only needs to name the keys it changes. +One development-only default worth noting: `reloadOnGlobalChange` (defaults `true` in development) makes bare `?reload=true` re-include `app/global/*.cfm` when any tracked file's mtime changes, so new helpers added to `app/global/functions.cfm` are picked up immediately without the password-gated full reload. Opt out with `set(reloadOnGlobalChange=false)` in `config/settings.cfm`. The setting defaults to `false` in all other environments, so there is no `DirectoryList` overhead in production. + ## Environment detection The active environment is set by `config/environment.cfm`, which typically reads `WHEELS_ENV` from the environment variables with a default of `development`. The `wheels` CLI respects the same variable, and `.env` files loaded at boot get merged into the lookup. Override in CI or production by exporting `WHEELS_ENV=production` before the app starts — a deploy script, a Dockerfile `ENV`, or a systemd unit is the usual place.