Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 21 additions & 18 deletions vendor/wheels/Global.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -28,45 +28,48 @@ component output="false" {
local = arguments;
}
// Include the template and return the result.
// Variable is set to $wheels to limit chances of it being overwritten in the included template.
// Every local is $-prefixed so it cannot shadow a same-named variable in
// the included view (#3518): the include runs inside this function's
// scope, so a bare `resolved`/`captured`/`fallbacks` here would otherwise
// win over the controller variable passed to the view.
// Include stays in this function: `local = arguments` above must be
// visible to partials, and savecontent must wrap the include itself
// (a helper on this output=false CFC would capture nothing).
// cfformat-ignore-start
local.resolved = $resolveGlobalIncludeTemplate(arguments.$template);
var includeState = {done = false, output = ""};
var captured = "";
local.$resolved = $resolveGlobalIncludeTemplate(arguments.$template);
var $includeState = {done = false, output = ""};
var $captured = "";
try {
savecontent variable="captured" {
include "#local.resolved#"
savecontent variable="$captured" {
include "#local.$resolved#"
};
includeState.output = captured;
includeState.done = true;
$includeState.output = $captured;
$includeState.done = true;
} catch (any e) {
if (!$isMissingMappedInclude(e)) {
rethrow;
}
}
if (!includeState.done) {
var fallbacks = $mappedIncludeFallbacks(local.resolved);
var fbCount = ArrayLen(fallbacks);
for (var fbIndex = 1; fbIndex <= fbCount; fbIndex++) {
if (!$includeState.done) {
var $fallbacks = $mappedIncludeFallbacks(local.$resolved);
var $fbCount = ArrayLen($fallbacks);
for (var $fbIndex = 1; $fbIndex <= $fbCount; $fbIndex++) {
try {
savecontent variable="captured" {
include "#fallbacks[fbIndex]#"
savecontent variable="$captured" {
include "#$fallbacks[$fbIndex]#"
};
includeState.output = captured;
includeState.done = true;
$includeState.output = $captured;
$includeState.done = true;
break;
} catch (any e) {
if (fbIndex == fbCount || !$isMissingMappedInclude(e)) {
if ($fbIndex == $fbCount || !$isMissingMappedInclude(e)) {
rethrow;
}
}
}
}
// cfformat-ignore-end
return includeState.output;
return $includeState.output;
}

/**
Expand Down
10 changes: 6 additions & 4 deletions vendor/wheels/Injector.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,12 @@ component implements="wheels.interfaces.di.InjectorInterface" {
structDelete(variables.requestScopedFlags, arguments.name);
structDelete(request, "$wheelsDICache");
} else if (structKeyExists(variables.mappings, arguments.name)) {
// Path re-map: keep the cached singleton for the dev-reload
// same-path pattern (to() invalidates it on a path CHANGE).
structDelete(variables.singletonFlags, arguments.name);
structDelete(variables.requestScopedFlags, arguments.name);
// Path re-map: keep the cached singleton AND its lifecycle flag
// (#3516). to() already invalidates the cached instance only on a
// path CHANGE (preserving it for the dev-reload same-path case), so
// dropping the flag here wrongly degrades a re-bound singleton to
// transient. Only clear the request-scoped instance cache; the
// singleton cache is invalidated by to() on a real path change.
structDelete(request, "$wheelsDICache");
}
variables.currentMapping = arguments.name;
Expand Down
6 changes: 6 additions & 0 deletions vendor/wheels/Model.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,12 @@ component output="false" displayName="Model" extends="wheels.Global"{
boolean base = true,
boolean useFilterLists = true
) {
// Register super<name> override aliases here too: the #3213 instance
// fast path (CreateObject + $initModelObject) never calls init(), so
// this is the one hook every instance path runs. Idempotent — names
// already registered on `this` by the legacy init() path are skipped.
$registerModelSuperAliases();

variables.wheels = {};
variables.wheels.instance = {};
variables.wheels.errors = [];
Expand Down
6 changes: 6 additions & 0 deletions vendor/wheels/tests/_assets/controllers/Test.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@ component extends="Controller" {
variableForLayout = "variableForLayoutContent";
}

function testResolved() {
// Named `resolved` on purpose: $includeAndReturnOutput used to declare a
// bare `local.resolved`, which shadowed this variable in the view (#3518).
resolved = {foo = "bar"};
}

function testRedirect() {
redirectTo(action = "dummy");
request.setInActionAfterRedirect = true;
Expand Down
23 changes: 23 additions & 0 deletions vendor/wheels/tests/_assets/models/SuperOverrideDelete.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
component extends="Model" {

function config() {
table("c_o_r_e_posts");
}

/**
* Overrides the delete() model mixin to prove the sanctioned 4.1.0
* delegation path — the parent-method call `super.delete()` (#3519). The
* legacy `variables.superdelete` alias is no longer invocable for a
* `delete` override, so this fixture delegates the way the upgrade guide
* now documents.
*/
public boolean function delete() {
variables.superDeleteInvoked = true;
return super.delete(argumentCollection = arguments);
}

public boolean function wasSuperDeleteInvoked() {
return StructKeyExists(variables, "superDeleteInvoked") && variables.superDeleteInvoked;
}

}
1 change: 1 addition & 0 deletions vendor/wheels/tests/_assets/views/test/testresolved.cfm
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<cfif IsStruct(resolved)>RESOLVED-IS-STRUCT<cfelse>RESOLVED-NOT-STRUCT</cfif>
30 changes: 30 additions & 0 deletions vendor/wheels/tests/specs/controller/SuperOverrideSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,36 @@ component extends="wheels.WheelsTest" {
expect(m.columnNames()).toStartWith("wrapped:")
})

it("registers super<name> on model instances built via the fast path", () => {
// #3515: new()/create()/finder rows are built by
// $createObjectFromRoot's CreateObject + $initModelObject fast path,
// which never calls init() — the aliases must register here too.
m = g.model("superOverride").new()

expect(StructKeyExists(m, "superColumnNames")).toBeTrue()
expect(m.columnNames()).toStartWith("wrapped:")
})

it("lets a delete() override delegate to the framework original via super.delete()", () => {
// #3519: the model-side variables.super<name> alias is gone in
// 4.1.0; the sanctioned delegation path is the parent-method call
// super.delete(). The fixture records the invocation so the spec
// proves the override actually ran (not just that a delete happened).
transaction {
post = g.model("superOverrideDelete").create(
title = "super-delete-test",
body = "body"
)

rv = post.delete()

expect(post.wasSuperDeleteInvoked()).toBeTrue()
expect(rv).toBeTrue()

transaction action = "rollback";
}
})

it("adds no super<name> keys to a controller that overrides nothing", () => {
// the else branch fires only on a genuine override, so the common case pays
// nothing — this runs on every request
Expand Down
6 changes: 6 additions & 0 deletions vendor/wheels/tests/specs/controller/miscellaneousSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ component extends="wheels.WheelsTest" {
expect(_controller.response()).toInclude("variableForViewContent")
})

it("does not shadow a view variable named resolved (##3518)", () => {
c = application.wo.controller("test", {controller = "test", action = "testResolved"})
c.$callAction(action = "testResolved")
expect(c.response()).toInclude("RESOLVED-IS-STRUCT")
})

it("is implicitly calling render page", () => {
_controller.$callAction(action = "test")
expect(_controller.response()).toInclude("view template content")
Expand Down
24 changes: 24 additions & 0 deletions vendor/wheels/tests/specs/di/InjectorLifecycleSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,30 @@ component extends="wheels.WheelsTest" {
expect(di.isSingleton("third")).toBeFalse();
});

it("re-mapping a singleton to a different path preserves the singleton lifecycle (##3516)", () => {
// Re-binding a singleton to a test double must stay a singleton —
// dropping the lifecycle flag on the path re-map degrades it to
// transient, so a counting spy/stub would be re-constructed on
// every resolve.
di.map("svc").to("wheels.tests._assets.di.SimpleService").asSingleton();
var first = di.getInstance("svc");
first.setMarker("original");
expect(first.getMarker()).toBe("original");

// Re-point to a different component path (test-double pattern).
di.map("svc").to("wheels.tests._assets.di.LifecycleHookService");

// The lifecycle flag must survive the re-map…
expect(di.isSingleton("svc")).toBeTrue();

// …and two resolves must return the same (new) instance, not a fresh
// transient on each call.
var second = di.getInstance("svc");
var third = di.getInstance("svc");
expect(second).toBe(third);
expect(second).notToBe(first);
});

});

}
Expand Down
24 changes: 13 additions & 11 deletions vendor/wheels/tests/specs/injector/InjectorHardenerSpec.cfc
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
/**
* Hardener proofs for Injector desk IDs S1–S10. Desk IDs stay locked.
*
* Flipped: S2 rebind reset, S3 fail-loud / flag the new key, S7 Duplicate copy.
* Flipped: S2 rebind reset (reversed by #3516 — a path re-map now preserves the
* singleton/request-scoped lifecycle; only request.$wheelsDICache is dropped),
* S3 fail-loud / flag the new key, S7 Duplicate copy.
* Unchanged on develop: S1, S4, S5, S6, S8, S9, S10. S4 stays dotted-path fallback.
*
* Directory-scoped so `wheels test --core --ci --filter=injector` discovers
Expand Down Expand Up @@ -38,28 +40,28 @@ component extends="wheels.WheelsTest" {

});

describe("S2 rebind resets flags and drops request cache", () => {
describe("S2 rebind preserves lifecycle and drops request cache", () => {

it("S2: rebind overwrites the path and resets the singleton flag", () => {
it("S2: rebind overwrites the path and preserves the singleton flag", () => {
di.map("rebind").to("wheels.tests._assets.di.SimpleService").asSingleton();
expect(di.isSingleton("rebind")).toBeTrue();
di.map("rebind").to("wheels.tests._assets.di.OptionalDependentService");
expect(di.getMappings()["rebind"]).toBe("wheels.tests._assets.di.OptionalDependentService");
expect(di.isSingleton("rebind")).toBeFalse();
expect(di.isSingleton("rebind")).toBeTrue();
expect(di.isRequestScoped("rebind")).toBeFalse();
var after = di.getInstance("rebind");
expect(after.hasDependency()).toBeFalse();
});

it("S2: rebind resets the request-scoped flag and drops request.$wheelsDICache", () => {
it("S2: rebind preserves the request-scoped flag and drops request.$wheelsDICache", () => {
di.map("rebindReq").to("wheels.tests._assets.di.SimpleService").asRequestScoped();
structDelete(request, "$wheelsDICache");
var first = di.getInstance("rebindReq");
first.setMarker("cached");
expect(structKeyExists(request, "$wheelsDICache")).toBeTrue();
expect(structKeyExists(request["$wheelsDICache"], "rebindReq")).toBeTrue();
di.map("rebindReq").to("wheels.tests._assets.di.OptionalDependentService");
expect(di.isRequestScoped("rebindReq")).toBeFalse();
expect(di.isRequestScoped("rebindReq")).toBeTrue();
expect(di.isSingleton("rebindReq")).toBeFalse();
expect(di.getMappings()["rebindReq"]).toBe("wheels.tests._assets.di.OptionalDependentService");
expect(structKeyExists(request, "$wheelsDICache")).toBeFalse();
Expand All @@ -73,7 +75,7 @@ component extends="wheels.WheelsTest" {
di.getInstance("rebindSolo");
di.map("rebindSolo").to("wheels.tests._assets.di.OptionalDependentService");
expect(structKeyExists(request, "$wheelsDICache")).toBeFalse();
expect(di.isSingleton("rebindSolo")).toBeFalse();
expect(di.isSingleton("rebindSolo")).toBeTrue();
});

it("S2: first bind of a new name does not drop request.$wheelsDICache", () => {
Expand All @@ -91,20 +93,20 @@ component extends="wheels.WheelsTest" {
expect(di.isSingleton("keepSolo")).toBeTrue();
expect(di.isRequestScoped("keepReq")).toBeTrue();
di.map("rebindOther").to("wheels.tests._assets.di.OptionalDependentService");
expect(di.isSingleton("rebindOther")).toBeFalse();
expect(di.isSingleton("rebindOther")).toBeTrue();
expect(di.isSingleton("keepSolo")).toBeTrue();
expect(di.isRequestScoped("keepReq")).toBeTrue();
expect(di.isSingleton("keepReq")).toBeFalse();
});

it("S2: same-path rebind still resets flags until asSingleton is called again", () => {
it("S2: same-path rebind preserves the flag and the cached instance", () => {
di.map("samePath").to("wheels.tests._assets.di.SimpleService").asSingleton();
var before = di.getInstance("samePath");
before.setMarker("stable");
di.map("samePath").to("wheels.tests._assets.di.SimpleService");
expect(di.isSingleton("samePath")).toBeFalse();
expect(di.isSingleton("samePath")).toBeTrue();
var mid = di.getInstance("samePath");
expect(mid.getMarker()).toBe("");
expect(mid.getMarker()).toBe("stable");
di.map("samePath").to("wheels.tests._assets.di.SimpleService").asSingleton();
expect(di.isSingleton("samePath")).toBeTrue();
});
Expand Down
28 changes: 28 additions & 0 deletions vendor/wheels/tests/specs/view/formsSpec.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -965,6 +965,34 @@ component extends="wheels.WheelsTest" {
expect(e).toBe(r)
})

it("resolves put/patch/delete resource member routes instead of throwing RouteNotFound (##3517)", () => {
// Resource member routes carry get/patch/put/delete — never post —
// so the route must be resolved with the ORIGINAL verb, then the
// HTML verb restored before rendering.
$clearRoutes()
g.mapper().resources(name = "customers").end()
g.$setNamedRoutePositions()

StructDelete(args, "controller")
args.route = "customer"
args.key = "1"

args.method = "delete"
r = _controller.startFormTag(argumentcollection = args)
expect(r).toInclude('method="post"')
expect(r).toInclude('name="_method" type="hidden" value="delete"')

args.method = "put"
r = _controller.startFormTag(argumentcollection = args)
expect(r).toInclude('method="post"')
expect(r).toInclude('name="_method" type="hidden" value="put"')

args.method = "patch"
r = _controller.startFormTag(argumentcollection = args)
expect(r).toInclude('method="post"')
expect(r).toInclude('name="_method" type="hidden" value="patch"')
})

it("works with multipart", () => {
args.multipart = "true"
argsction = _controller.urlfor(argumentCollection = args)
Expand Down
12 changes: 12 additions & 0 deletions vendor/wheels/view/forms.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,15 @@ component {
// the original verb for the `_method` hidden field (empty string when none applies).
local.method = $startFormTagMethod(args = arguments);

// HTML forms only support get/post, but the named-route lookup must use
// the ORIGINAL verb (put/patch/delete) — resource member routes never
// declare `post` (#3517). Remember the HTML verb and restore it after
// the route is resolved.
local.formMethod = arguments.method;
if (Len(local.method)) {
arguments.method = local.method;
}

// set the form's action attribute to the URL that we want to send to
local.encodeExcept = "";
local.skipCsrf = false;
Expand Down Expand Up @@ -111,6 +120,9 @@ component {
local.skip = ListDeleteAt(local.skip, ListFind(local.skip, "action"));
}

// Restore the HTML verb before rendering <form method="...">.
arguments.method = local.formMethod;

local.rv = arguments.prepend & $tag(
name = "form",
skip = local.skip,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ If you deploy from git and `vendor/wheels/` is committed (common for deliberate-
4.1.0 hardens `public/Application.cfc`'s `onError` and `onSessionEnd` handlers against a torn-down application scope during `applicationStop()` and session reaping ([#3379](https://github.com/wheels-dev/wheels/issues/3379)). The `vendor/wheels/` swap above does **not** update this app-owned file. On Adobe CF 2023/2025, diff your `public/Application.cfc` against the 4.1.0 base template and adopt the guarded handlers — or run `wheels upgrade check`, which flags the drift automatically.
</Aside>

<Aside type="caution" title="Model overrides delegating via `variables.super<name>`">
4.1.0 moved the model mixins to compile-time includes ([#3213](https://github.com/wheels-dev/wheels/issues/3213)), so a model that overrides a mixin and delegates through the old `variables.super<name>` alias (e.g. `variables.superdelete()`) must call the parent method instead — `super.delete()` (or `super.updateAll()`, etc.). Controller and view helper `super<name>` overrides are unaffected ([#3325](https://github.com/wheels-dev/wheels/issues/3325)).
</Aside>

## The 3.x → 4.0 upgrade

The mechanics are the same swap, but 4.0 changes framework behavior and some app-side files, so there's required reading and a few extra steps:
Expand Down
Loading