Skip to content
Merged
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<cfinclude>`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)

----

Expand Down
81 changes: 81 additions & 0 deletions vendor/wheels/Global.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
31 changes: 31 additions & 0 deletions vendor/wheels/events/EventMethods.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
4 changes: 4 additions & 0 deletions vendor/wheels/events/init/orm.cfm
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
4 changes: 4 additions & 0 deletions vendor/wheels/events/onapplicationstart.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
115 changes: 115 additions & 0 deletions vendor/wheels/tests/specs/global/reloadGlobalsSpec.cfc
Original file line number Diff line number Diff line change
@@ -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", "<cfscript>function fxA(){return 1;}</cfscript>");
FileWrite(ctx.baseDir & "/fixtureB.cfm", "<cfscript>function fxB(){return 2;}</cfscript>");
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", "<cfscript>function fxStable(){return 'stable';}</cfscript>");
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", "<cfscript>function fxOne(){return 1;}</cfscript>");
var snapshot = ctx.g.$snapshotGlobalIncludes(directory = ctx.baseDir);
FileWrite(ctx.baseDir & "/two.cfm", "<cfscript>function fxTwo(){return 2;}</cfscript>");
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", "<cfscript>function fxKeep(){return 1;}</cfscript>");
FileWrite(ctx.baseDir & "/gone.cfm", "<cfscript>function fxGone(){return 2;}</cfscript>");
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", "<cfscript>function fxAdded(){return 1;}</cfscript>");
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", "<cfscript>function fxV1(){return 1;}</cfscript>");
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, "<cfscript>function fxReinclude(){return 'first';}</cfscript>");
$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, "<cfscript>function fxReinclude(){return 'second';}</cfscript>");
$assert.notThrows(function() {
application.wo.$reincludeGlobals(file = "/wheels/tests/_tmp/reloadGlobals/reinclude.cfm");
});
expect(application.wo.fxReinclude()).toBe("second");
});

});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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: <message>` and hints at setting `RELOAD_PASSWORD` in `.env` or `config/settings.cfm`.

<Aside type="note" title="When you need reload">
Most CFML source changes are picked up on the next request without a reload. You need `wheels reload` when you change config that's only read at bootstrap — routes, settings, DI bindings, package manifests — or when you want to force every per-request cache to clear.
Most CFML source changes are picked up on the next request without a reload. In development, changes to `app/global/functions.cfm` (and any files it `<cfinclude>`s) are also auto-detected on bare `?reload=true` (the `reloadOnGlobalChange` setting). You need `wheels reload` when you change config that's only read at bootstrap — routes, settings, DI bindings, package manifests — or when you want to force every per-request cache to clear.
</Aside>

## Dev loop example
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ set(environment = application.wo.env("WHEELS_ENV", "development"));

Settings cascade: framework defaults load first, then `config/settings.cfm`, then `config/<environment>/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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: <message>` and hints at setting `RELOAD_PASSWORD` in `.env` or `config/settings.cfm`.

<Aside type="note" title="When you need reload">
Most CFML source changes are picked up on the next request without a reload. You need `wheels reload` when you change config that's only read at bootstrap — routes, settings, DI bindings, package manifests — or when you want to force every per-request cache to clear.
Most CFML source changes are picked up on the next request without a reload. In development, changes to `app/global/functions.cfm` (and any files it `<cfinclude>`s) are also auto-detected on bare `?reload=true` (the `reloadOnGlobalChange` setting). You need `wheels reload` when you change config that's only read at bootstrap — routes, settings, DI bindings, package manifests — or when you want to force every per-request cache to clear.
</Aside>

## Dev loop example
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ set(environment = application.wo.env("WHEELS_ENV", "development"));

Settings cascade: framework defaults load first, then `config/settings.cfm`, then `config/<environment>/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.
Expand Down
Loading